diff --git a/.github/workflows/tests-crates.yml b/.github/workflows/tests-crates.yml index 40cc572cc..425dca68c 100644 --- a/.github/workflows/tests-crates.yml +++ b/.github/workflows/tests-crates.yml @@ -21,6 +21,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-encrypt/** + - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** - languages/typescript/packages/auth/** @@ -44,6 +45,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-encrypt/** + - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** - languages/typescript/packages/auth/** diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 868a65fc1..d9a6cc36e 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -38,6 +38,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-encrypt/** + - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** # The eql guest build links eql-bindings. @@ -57,6 +58,7 @@ on: - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh + - scripts/stack-conformance.sh - Cargo.toml - Cargo.lock - .cargo/** @@ -74,6 +76,7 @@ on: - packages/stack-profile/** - packages/stack-kms/** - packages/stack-encrypt/** + - conformance/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** # The eql guest build links eql-bindings. @@ -93,6 +96,7 @@ on: - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh + - scripts/stack-conformance.sh - Cargo.toml - Cargo.lock - .cargo/** @@ -119,6 +123,7 @@ env: RUSTFLAGS: "-D warnings" RUST_BACKTRACE: full CARGO_TERM_COLOR: always + STACK_CONFORMANCE_REQUIRED: "1" CARGO_NET_GIT_FETCH_WITH_CLI: true jobs: @@ -250,6 +255,9 @@ jobs: echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB" mise run go:test + - name: Shared Stack Encrypt conformance and fresh Go to Rust opening + run: mise run test:conformance + # The Rust suite opens the committed v4_rows.json and # v4_smallint_rows.json, whose ciphertexts the Go SDK sealed when each # file was last regenerated. This also hands it the rows the Go SDK diff --git a/conformance/stack-encrypt/README.md b/conformance/stack-encrypt/README.md new file mode 100644 index 000000000..61eb84625 --- /dev/null +++ b/conformance/stack-encrypt/README.md @@ -0,0 +1,119 @@ +# Stack Encrypt conformance + +This directory holds a shared test contract for Stack Encrypt bindings. The +[implementation plan](../../docs/plans/2026-10-31-stack-encrypt-conformance.md) +sets out the wider work; [coverage.md](coverage.md) maps existing evidence and +gaps. These profiles are **experimental**, not certification of an entire SDK. + +## Run + +From the repository root, with the pinned mise tools installed: + +```sh +mise run wasm:guest:build:deterministic +mise run test:conformance +``` + +No credentials or database are required. The task refuses a missing test guest. +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. + +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-declarations-v1` contract + +The [declaration grammar](declaration-grammar.md) defines the engine's current +record-plan syntax, defaults, context rules, kind/index table and null policies. +`v1/declarations.json` contains 201 cases, including malformed frames and all +21 kinds against five scalar indexes. The resolver is explicitly `none`; EQL +targets need their own profile. + +The runners are `packages/stack-encrypt/tests/declarations.rs` and +`languages/golang/encrypt/declaration_conformance_test.go`. Both call the engine +without a cipher. Valid-frame declaration diagnostics agree exactly; malformed +frames document the different native-parser and WASI-boundary classifications. +Native Rust also checks normalization observations. Go's checker does not expose +the parsed plan, so it asserts acceptance and diagnostics. + +## `record-scalars-v1` contract + +`v1/cases.json` has a corpus format `version`, a semantic `profile`, a public +deterministic-provider `seed`, a record `context`, a `field`, an exact list of +`required_capabilities`, and uniquely identified `cases`. Unknown fields, +versions, profiles, capabilities and input types fail. Every case must run. + +The profile fixes a one-field record with ciphertext (`c`) and equality (`eq`) +outputs. Its field context is the record context followed by the field name. +Each case declares the field's `kind` and a typed `input`. `input.text` is the +literal string value, canonical base-10 integer, `true`/`false`, or empty text +for null, as determined by `input.type`. Runners construct real typed host +values, preserving integer width. JSON numbers are deliberately not used for +values: future TypeScript runners must not pass 64-bit integers through Number. + +The initial positives cover int32 and int64 bounds, uint64 max, an integer +above JavaScript's safe-integer limit, Unicode text and empty text. For each: + +1. The encoded typed frame must equal the frozen frame, including its type. +2. Record encryption and opening must preserve both value and type. +3. An equality query must produce one exact `eq` operand, encoded as `bytes`, + without an EQL domain. Its bytes must equal the frozen equality term and + the term stored with the record. +4. The frozen native owned-scalar ciphertext must open through the record + path. Native Rust additionally opens it through the owned scalar API. +5. Fresh Go ciphertext must open in Rust through both paths. Ciphertext bytes + themselves are not compared: encryption is randomized. +6. Opening with `wrong` appended to the record context (before the field name) + must refuse key retrieval as forbidden. + This expectation uses `stack_kms::DeterministicSource`, whose descriptor + binding models ZeroKMS's refusal. It is distinct from an AEAD failure and + does not imply every provider returns the same error shape. + +Negative cases have `expected_error` and exercise the equality query only. +They require the exact engine diagnostic `code`, `reason`, and field name; +Go must also expose `ErrEncoding`. Native Rust verifies rejection occurs +before data-key generation or retrieval. String/bool inputs for an integer +field and a null query are currently covered. Error prose is not contractual. + +Boolean equality is not admitted by the current engine. Boolean scalar +encryption, nullable records, other numeric kinds, bytes, JSON, EQL targets, +other operators, declarations, cancellation and provider failures need later +profiles or cases. The full SQL behavior is not part of this initial profile. + +## Artifacts and evolution + +`v1/native.json` is a frozen artifact set produced by the Rust owned scalar API. +It records `version`, `profile`, `producer`, and a case-ID map containing hex +`frame`, `ciphertext` and `equality` bytes. The map must contain exactly every +positive case; missing or extra cases fail. Every key and plaintext here is +public test material. The provider seed is not a production key mechanism. + +The runners accept `STACK_CONFORMANCE_OUTPUT` to export fresh artifacts; Rust +accepts `STACK_CONFORMANCE_INPUT` to validate another producer. The task isolates +these variables and sets `STACK_CONFORMANCE_REQUIRED=1`, preventing a missing +Go test guest from turning conformance into a skipped test. Ordinary Go test +runs may skip this suite when the guest has not been built. + +To intentionally create or update native artifacts: + +```sh +STACK_CONFORMANCE_UPDATE=1 mise x --env test -- cargo nextest run --locked \ + -p stack-encrypt --all-features --test conformance +mise run test:conformance +``` + +Review the case and artifact diff together. Never regenerate artifacts merely +to make a regression pass. Existing IDs retain their meaning; incompatible +expectations require a new profile. Corpus version 1 is a test format, not a +production wire version. Ciphertext/storage versions remain owned by their +existing codecs. + +Both runners currently share the Rust engine. This checks host value conversion, +native/WASI interoperability and regressions against frozen evidence; it cannot +detect every bug shared by both paths. Independent SQL oracles, broader negative +cases, release-version matrices and richer version reports remain in the plan. diff --git a/conformance/stack-encrypt/coverage.md b/conformance/stack-encrypt/coverage.md new file mode 100644 index 000000000..53ff44d27 --- /dev/null +++ b/conformance/stack-encrypt/coverage.md @@ -0,0 +1,51 @@ +# Coverage map + +Snapshot: PR #1404 at `b684a217e`, including the JSON matching work through #1452. +This is an inventory of evidence, not a claim that every suite was rerun here. +Paths are relative to the repository root. New profile cases run from `v1/`. + +| Requirement | Existing evidence | Remaining work | +|---|---|---| +| Scalar value widths and lossless transport | `packages/stack-encrypt/tests/value_kinds.rs`; `languages/golang/encrypt/value_kinds_test.go`; initial shared scalar cases | Expand shared cases to decimals, dates, timestamps, floats, bytes and all widths; Python/TS mappings | +| Owned opaque ciphertext and storage envelope | `packages/stack-encrypt/tests/scalar.rs`, `frozen_bytes.rs`; initial cross-opening cases | Blob profile; encoded targets; supported version matrix | +| Record declaration and lowering | [Declaration grammar](declaration-grammar.md); `v1/declarations.json` shared by native Rust and Go; native normalization observations; existing record lowering suites | EQL-resolver profile; SDK-to-declaration mappings; runtime source/read shapes | +| Context binding and composition | `packages/stack-encrypt/tests/target.rs`, `descriptor.rs`, `record_lowering.rs`; `languages/golang/encrypt/context_field_test.go`; initial wrong-context cases | Shared composition vectors; expected-context and required-input matrix | +| Query terms and diagnostics | `packages/stack-encrypt/tests/query_operators.rs`; `languages/golang/encrypt/query_test.go`; initial equality/query-error cases | Common rejection corpus for every operator, capability and input shape | +| Missing, SQL NULL and sealed null | `packages/stack-encrypt/src/dynamic/record.rs` null tests; `languages/golang/encrypt/null_test.go` | Explicit language mappings and common missing/null fixtures | +| Fresh cross-language storage | `.github/workflows/tests-golang.yml` exports fresh Go EQL rows for Rust; initial scalar artifact exchange | Every producer/consumer/backend pair and supported released versions | +| JSON queries, extraction and tampering | `packages/stack-encrypt/tests/json_tree_vectors.rs`; `packages/eql/tests/encryption/tests/json_tree.rs`; `languages/golang/encrypt/json_tree_test.go`, `json_postgres_test.go` | Formal operation profile; JSON vectors consumed by all runners | +| Element and predicate matching | Go `json_elements_test.go`, `json_match_test.go` and corresponding PostgreSQL tests; Rust/SQL predicate suites | Map exact supported grammar and documented semantic differences to case IDs | +| Generated SDK declarations | Go stashgen and generated-code fixture tests | Python and TypeScript public-API runners; compiler/type-checker cases | +| Validation before key I/O and batching | Rust `tests/common::CountingSource` and record/query tests; guest preflight tests | Requirement-by-requirement coverage; common provider observations | +| Binding lifecycle and cleanup | `packages/stack-guest-abi`; Go `cancel_test.go`, `step_test.go`, guest memory tests | Native Python/TS and WASM backend-specific matrices; no blanket host-erasure claim | +| Version/capability negotiation | Existing ABI status table and target/operator discovery | Normative supported-version rules; reject unknown required capabilities | + +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. + +## 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. + +| Existing evidence | Relationship to conformance | Consolidation action | +|---|---|---| +| Rust `dynamic/record.rs` tests `a_malformed_plan`, `a_null_is_refused_as_its_key`, `a_missing_or_null_index_payload_is_the_default` | Many inputs and diagnostic outcomes are now shared declaration cases | Move the remaining cases and retain any unique display/low-level assertions before removing duplicate rows | +| Rust kind/index admission tests | The corpus has the complete 21-kind/five-index acceptance matrix | Share case data; retain direct tests that compare declaration admission with actual term execution | +| Rust `value_kinds.rs`, Go `value_kinds_test.go` | Already share `value_kinds.json`; cover more types and generated SDK code than the initial scalar profile | Enroll that existing fixture under named requirements instead of copying it; keep generated-code assertions | +| Rust `query_operators.rs`, Go `query_test.go` | Already share operator vectors, including verification and target limits | Reuse their fixture and cases when adding an operator profile; preserve host/generated query mapping tests | +| Go checker and host validation tests in `guest_test.go` | Exercise host rejection and diagnostic forwarding through public checker methods | Keep adapter-specific assertions; raw-frame conformance bypasses host validation deliberately | +| Native scalar/record round trips and cross-opening | Some behavior overlaps, but existing tests cover more types and API paths | Replace only exact duplicates after matching requirements and execution paths | + +Keep Rust compile-fail, ownership/zeroization, batching, fuzz/property, and +implementation regression tests. Keep Go generation, host type conversion, +error wrapping, cancellation and guest lifecycle tests. Keep independent +PostgreSQL oracles and live-provider tests. These observe guarantees a shared +engine corpus cannot prove by itself. + +For each removal, link the old assertion to stable case IDs, compare coverage +and failure behavior, and run both runners in CI. Preserve unrelated assertions +from mixed tests. Enroll Python and TypeScript against the existing corpus so +adding a language does not create another copy of the expected behavior. diff --git a/conformance/stack-encrypt/declaration-grammar.md b/conformance/stack-encrypt/declaration-grammar.md new file mode 100644 index 000000000..518e47195 --- /dev/null +++ b/conformance/stack-encrypt/declaration-grammar.md @@ -0,0 +1,276 @@ +# Record declaration grammar + +Use this contract when a binding lowers an SDK declaration to the engine's record +plan. Send the complete plan to the engine checker before executing it. + +For example, this declaration seals and equality-indexes an int64 field: + +```json +{ + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int64" + } +} +``` + +Run the executable cases with `mise run test:conformance`, after building the +deterministic test guest as described in [README.md](README.md). +`ciphertext-untyped`, `index-only`, and every `type-*-eq` case exercise these forms. + +## Scope and authority + +`record-declarations-v1` describes the engine at PR #1404's `b684a217e`, not future +SDK syntax. It is an experimental conformance profile, not a new production wire +version. The input is a Vitamin C **typed transport frame**. JSON below is a +readable projection; it cannot distinguish every integer width or carry bytes. + +The executable authority is `stack_encrypt::dynamic::record::plan_with`, with the +selected target resolver. Native `record::plan` and the Go guest without EQL use +`NoTargets`. The corpus fixes that resolver explicitly as `none`. It checks +target syntax and unavailable-target refusals; it does not certify EQL targets. + +The full contract has three parts: frame validity, the structural grammar below, +and the semantic constraints. A shape-only JSON Schema would not enforce the +context, index, null or resolver rules. SDK tooling must ask the engine instead +of implementing those rules again. Test runners convert fixture values and +observe results; they do not provide another semantic validator. + +## Structural grammar + +This is a typed grammar. `Object{...}` denotes a transport object, `List` a +transport array, `?` an optional member, and `|` an alternative. Literal words +denote text values. Each object has exactly the listed keys, except `Plan`, whose +non-reserved keys are field names. Object key order is unrestricted. + +```text +Plan = Object{ + "context_field"?: Text, + (FieldName: FieldSpec)+ +} +FieldName = Text except "context_field" + +FieldSpec = Object{ + "context": FieldContext, + ("outputs": NonEmptyList | "target": Target), + "type"?: ValueKind, + "null"?: "sealed" | "sql" +} + +FieldContext = Label | [FieldContext, ContextScalar] +Label = NonEmptyList +ContextScalar = Text | Bytes | Int32 | Int64 | UInt32 | UInt64 + +UnitOutput = "c" | "passthrough" | "eq" | "ore" | "ope" | "ore_block" +Output = UnitOutput | Object{UnitOutput: Null} + | "match" | Object{"match": Null | MatchOptions} + | "json" | Object{"json": Null | JsonOptions} + +MatchOptions = Object{ + "tokenizer"?: Tokenizer, + "downcase"?: Bool, + "short_words"?: Bool, + "k"?: Integer, + "m"?: Integer +} +Tokenizer = "standard" | Object{"standard": Null} + | Object{"ngram": Integer} + +Target = NonEmptyText | Object{ + "name": NonEmptyText, + "options"?: JsonOptions +} +JsonOptions = Object{ + "sealed_root"?: Bool, + "ordered"?: List +} + +ValueKind = "bool" + | "int8" | "int16" | "int32" | "int64" | "int128" + | "uint8" | "uint16" | "uint32" | "uint64" | "uint128" + | "float32" | "float64" | "decimal" | "date" | "timestamp" + | "string" | "bytes" | "array" | "object" | "json" +``` + +`outputs` and `target` are exclusive and one must be present. Present optional +members must have the declared type: `"type": null` is not omission. +The explicit null alternatives for enum payloads are exceptions, pinned by the +`unit-output-null-*` and `match-default-*` cases. Unknown keys and output/type +names fail; no unknown option is ignored. + +`Integer` here means an integer-valued transport tag accepted by the option's +integer reader, within its bounds. It does not mean a floating-point value that +happens to be integral. Width is not an option's identity. Context integer width, +in contrast, is part of context identity and must survive parsing unchanged. + +Every object key must be unique, including keys nested inside options. The frame +must contain exactly one complete value within the codec's depth and size bounds. +Duplicate keys, truncation and trailing data fail before declaration semantics. +Do not change these codec rules in this specification. + +## Context semantics + +Try the `Label` alternative before decomposing an extension. A flat text list +is a label; `["users", "region", "value"]` has three segments. It is not the +extended context `[["users", "value"], "region"]`. Extensions nest to the left. +An extension is scalar, so `[["users", "value"], ["tenant"]]` is refused. +Bare `"users/value"` is not a field label. + +A plain segment is nonempty and cannot contain `/`, parentheses, control +characters, or the invisible format characters listed by `Label::check_segment` +in `packages/stack-encrypt/src/descriptor.rs`. It cannot start with `b64:`, `-`, +or an ASCII digit. That shared label rule is referenced rather than reimplemented +by a binding. It applies to label segments, not SDK variable names. + +Without `context_field`: + +- Every field label has at least two segments. +- The last segment is the field's identity. All preceding segments form the + plan label, which must agree across fields. +- All fields have the same ordered extension values, including their types. +- Sealed/indexed field identities cannot collide. The field's map key and its + identity may differ; renaming a map key does not implicitly change its label. + +With `context_field`: + +- Its value names a declared field, whose sole output is `passthrough`. +- That field declares `string` or omits `type`; omission resolves to `string`. +- Every field label has exactly one segment: its identity. +- Extensions still agree across fields. No target field is admitted. +- At execution, each record supplies the named field's label value. Its validity + and the opener's expected-context constraint are runtime requirements. + +The grammar does not add call-time context roles or defaults. Context composition +is observed by `extended-context`, `flat-label-is-not-extension`, the +`typed-extension-*` cases, and the mixed-context refusals. + +## Output and kind semantics + +An output list is nonempty. Output keys are unique after reading their options: +two differently configured match indexes still collide on `match`. +`passthrough` is always the sole output. Ciphertext is explicit; an index-only +declaration does not implicitly acquire `c`. + +Every field with a scalar index declares `type`. Ciphertext-only and passthrough +fields may omit it. Declaring a kind checks values during execution; it does not +select a different leaf encoding. + +| Declared kind | `eq` | `match` | `ore`, `ope`, `ore_block` | +|---|---|---|---| +| bool | Refused | Refused | Refused | +| int8, uint8 | Admitted | Refused | Refused | +| Other integer widths | Admitted | Refused | Admitted | +| float32, float64, decimal, date, timestamp | Admitted | Refused | Admitted | +| string | Admitted | Admitted | Admitted | +| bytes | Admitted | Refused | Admitted | +| array, object, json | Refused | Refused | Refused | + +The corpus explicitly tests all 21 kinds against all five scalar indexes. +`array` and `object` may still be sealed without indexes. A `json` field requires +a document target. A raw `json` output has a recognized syntax but is refused as +`json_index_pending`; it is not an alternative to a document target. + +Match defaults are 3-grams, `downcase: true`, `short_words: false`, `k: 3`, and +`m: 256`. Every missing option takes its default. The bounds are: + +- `3 <= k <= 16`. +- `m` is a power of two from 32 through 65536. +- N-gram length is nonzero and representable by the implementation's integer reader. +- `short_words: true` requires n-grams of length at least three. + +Standard tokenization cannot enable short words. An invalid option fails when +the declaration is checked, before values or keys are needed. + +## Null semantics + +Omitting `null` keeps the field required. A typed field then refuses null values; +an untyped value's ability to represent null is not an implicit nullable column. +`null` is a storage policy, distinct from a null option payload or a missing key. + +| Policy | Declaration requirements | Runtime meaning | +|---|---|---| +| `sql` | Any non-passthrough field | Missing/null source leaves no encrypted field output; the host stores SQL NULL | +| `sealed` | A fixed-width bool, number, decimal, date or timestamp; only equality indexes, if any | Missing/null source is sealed with type-appropriate padding; equality uses the field's null term | + +Text, bytes and composite kinds cannot request sealed null. Passthrough cannot +request either policy. These cases check declaration admissibility only; runtime +padding, queries, missing values and round trips retain their existing tests. + +## Targets and JSON options + +Target syntax is parsed before resolver lookup. `options` takes JSON-index +options; a null options object or unknown option is an invalid target. +`sealed_root` defaults to true, and `ordered` to an empty set. +Paths are parsed by `JsonPath::parse`; ordered paths cannot select array positions. +Repeated equivalent paths collapse into the set. + +Under this profile's `none` resolver, every otherwise valid target produces +`stack_encrypt::target_none` with its target name. A missing target capability +must never silently turn into a ciphertext-only field. + +For a resolver that supplies EQL types, additional existing rules apply: the +target must be producible, its column label has exactly two segments, and the +plan has neither extensions nor a context field. An explicit kind matches the +target's plaintext kind; otherwise that kind is inferred. Only a document target +accepts JSON options. Sealed-null rules include the target's own indexes. +Those resolver-dependent outcomes and normalization need an EQL profile; this +corpus does not claim they have been verified across bindings. + +## Normalization observations + +The native runner observes the parsed plan directly. Its `normalized` fixture +field is a test report, not a canonical production serialization: + +- `label` and `context_field` identify the resolved context source. +- `extension` lists typed scalar parts, preserving width and order. +- `fields` retains transport field order, label segments, resolved kind, + null policy and output order. +- Unit output aliases become bare keys. Default match options become `"match"`. + Non-default match options are explicit; `short_words` is emitted only when true. + +No binding should sort production fields, flatten contexts, coerce context +integers, or infer omitted ciphertext outputs from these observations. +`frame-field-order` checks field order independently of the JSON projection. + +The Go plan-check export returns success or a diagnostic, not a normalized plan. +Its runner therefore verifies acceptance and diagnostics. It does not claim an +independent observation of normalization or add a production export for testing. + +## Diagnostics and corpus format + +The corpus is `v1/declarations.json`. Its version, profile and resolver are +required. Case IDs are unique and stable. Every case contains exactly one input: + +- `{"json": value}` converts JSON objects in lexical key order. Nonnegative + integers become uint64, negative integers int64, and fractional values float64. + Arrays preserve order. This is fixture conversion, not SDK policy. +- `{"frame_hex": text}` supplies exact transport bytes, including typed contexts, + original field order and malformed frames that JSON cannot represent. + +`accepted` is mandatory. Refused cases require `expected_error`, containing the +native engine diagnostic code and structured payload. Accepted cases may include +`normalized`. Runners reject unknown corpus keys and contradictory outcomes. + +A malformed transport frame also has `boundary_error`. The native parser reports +`stack_encrypt::dynamic_plan` with `not_an_object`; the WASI frame check reports +`stack_guest_abi::malformed_input` before reaching that parser. Both runners verify +the frame really is malformed. Valid-frame declaration errors must agree exactly +on code and payload; Go also requires `ErrEncoding`. Error prose is not contractual. + +Cases isolate a failure where practical. This profile does not define universal +error precedence for declarations containing several independent faults. +Neither runner initializes a cipher to check declarations. The Go checker has +no credentials and uses a refusing transport. + +## Evidence and limits + +The corpus covers 201 cases: 91 accepted and 110 refused, including the complete +scalar kind/index matrix. The native runner also checks selected normalization +observations. Existing codec tests own exhaustive frame validity, depth limits, +and integer-reader behavior. Existing EQL tests own resolver/catalog behavior. +Runtime operations and language-specific declarations remain separate layers. + +See [coverage.md](coverage.md) for the consolidation policy. New Python and +TypeScript runners can adopt these inputs and expectations without copying the +engine's validation rules. diff --git a/conformance/stack-encrypt/v1/cases.json b/conformance/stack-encrypt/v1/cases.json new file mode 100644 index 000000000..ecb01f633 --- /dev/null +++ b/conformance/stack-encrypt/v1/cases.json @@ -0,0 +1,115 @@ +{ + "version": 1, + "profile": "record-scalars-v1", + "seed": "0000000000000000000000000000000000000000000000000000000000000000", + "context": ["conformance"], + "field": "value", + "required_capabilities": [ + "records", + "equality", + "context-binding", + "query-errors" + ], + "cases": [ + { + "id": "i32-min", + "kind": "int32", + "input": { + "type": "int32", + "text": "-2147483648" + } + }, + { + "id": "i32-max", + "kind": "int32", + "input": { + "type": "int32", + "text": "2147483647" + } + }, + { + "id": "i64-min", + "kind": "int64", + "input": { + "type": "int64", + "text": "-9223372036854775808" + } + }, + { + "id": "i64-max", + "kind": "int64", + "input": { + "type": "int64", + "text": "9223372036854775807" + } + }, + { + "id": "u64-max", + "kind": "uint64", + "input": { + "type": "uint64", + "text": "18446744073709551615" + } + }, + { + "id": "above-js-safe-integer", + "kind": "int64", + "input": { + "type": "int64", + "text": "9007199254740993" + } + }, + { + "id": "text-unicode", + "kind": "string", + "input": { + "type": "string", + "text": "café 東京 🔐" + } + }, + { + "id": "text-empty", + "kind": "string", + "input": { + "type": "string", + "text": "" + } + }, + { + "id": "query-text-for-integer", + "kind": "int64", + "input": { + "type": "string", + "text": "sensitive-query-value" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type" + } + }, + { + "id": "query-bool-for-integer", + "kind": "int64", + "input": { + "type": "bool", + "text": "true" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "field_type" + } + }, + { + "id": "query-null", + "kind": "int64", + "input": { + "type": "null", + "text": "" + }, + "expected_error": { + "code": "stack_encrypt::dynamic_source", + "reason": "null_query" + } + } + ] +} diff --git a/conformance/stack-encrypt/v1/declarations.json b/conformance/stack-encrypt/v1/declarations.json new file mode 100644 index 000000000..a45223608 --- /dev/null +++ b/conformance/stack-encrypt/v1/declarations.json @@ -0,0 +1,3278 @@ +{ + "version": 1, + "profile": "record-declarations-v1", + "resolver": "none", + "cases": [ + { + "id": "ciphertext-untyped", + "input": { + "json": { "value": { "context": ["users", "value"], "outputs": ["c"] } } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "passthrough-untyped", + "input": { + "json": { + "value": { "context": ["users", "value"], "outputs": ["passthrough"] } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["passthrough"] + } + ] + } + }, + { + "id": "index-only", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["eq"], + "type": "int64" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "int64", + "null": null, + "outputs": ["eq"] + } + ] + } + }, + { + "id": "outputs-retain-order", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["ore", "c", "eq"], + "type": "int64" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "int64", + "null": null, + "outputs": ["ore", "c", "eq"] + } + ] + } + }, + { + "id": "unit-output-null-c", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "c": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "unit-output-null-passthrough", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "passthrough": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["passthrough"] + } + ] + } + }, + { + "id": "unit-output-null-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "eq": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["eq"] + } + ] + } + }, + { + "id": "unit-output-null-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "ore": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["ore"] + } + ] + } + }, + { + "id": "unit-output-null-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "ope": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["ope"] + } + ] + } + }, + { + "id": "unit-output-null-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "ore_block": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["ore_block"] + } + ] + } + }, + { + "id": "match-default-bare", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["match"], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["match"] + } + ] + } + }, + { + "id": "match-default-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": null }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["match"] + } + ] + } + }, + { + "id": "match-default-empty", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": {} }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["match"] + } + ] + } + }, + { + "id": "match-default-explicit", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [ + { + "match": { + "tokenizer": { "ngram": 3 }, + "downcase": true, + "short_words": false, + "k": 3, + "m": 256 + } + } + ], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": ["match"] + } + ] + } + }, + { + "id": "match-custom", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [ + { + "match": { + "tokenizer": "standard", + "downcase": false, + "k": 4, + "m": 512 + } + } + ], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": [ + { + "match": { + "tokenizer": "standard", + "downcase": false, + "k": 4, + "m": 512 + } + } + ] + } + ] + } + }, + { + "id": "tokenizer-standard-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "tokenizer": { "standard": null } } }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": [ + { + "match": { + "tokenizer": "standard", + "downcase": true, + "k": 3, + "m": 256 + } + } + ] + } + ] + } + }, + { + "id": "match-short-words", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "short_words": true } }], + "type": "string" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "string", + "null": null, + "outputs": [ + { + "match": { + "tokenizer": { "ngram": 3 }, + "downcase": true, + "short_words": true, + "k": 3, + "m": 256 + } + } + ] + } + ] + } + }, + { + "id": "extended-context", + "input": { + "json": { + "value": { + "context": [[["users", "value"], 7], "eu"], + "outputs": ["c"] + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [ + { "type": "uint64", "text": "7" }, + { "type": "string", "text": "eu" } + ], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "flat-label-is-not-extension", + "input": { + "json": { + "value": { "context": ["users", "region", "value"], "outputs": ["c"] } + } + }, + "accepted": true, + "normalized": { + "label": ["users", "region"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "region", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "sealed-null-equality", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int32", + "null": "sealed" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "int32", + "null": "sealed", + "outputs": ["c", "eq"] + } + ] + } + }, + { + "id": "sealed-null-bool", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "type": "bool", + "null": "sealed" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": "bool", + "null": "sealed", + "outputs": ["c"] + } + ] + } + }, + { + "id": "sql-null-untyped", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "null": "sql" + } + } + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": "sql", + "outputs": ["c"] + } + ] + } + }, + { + "id": "context-field-infers-string", + "input": { + "json": { + "context_field": "tenant", + "tenant": { "context": ["tenant"], "outputs": ["passthrough"] }, + "value": { "context": ["value"], "outputs": ["c"] } + } + }, + "accepted": true, + "normalized": { + "label": null, + "context_field": "tenant", + "extension": [], + "fields": [ + { + "name": "tenant", + "label": ["tenant"], + "type": "string", + "null": null, + "outputs": ["passthrough"] + }, + { + "name": "value", + "label": ["value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "type-bool-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "bool" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-bool-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "bool" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-bool-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "bool" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-bool-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "bool" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-bool-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "bool" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int8-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int8" + } + } + }, + "accepted": true + }, + { + "id": "type-int8-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "int8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int8-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int8-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "int8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int8-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "int8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int16-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int16" + } + } + }, + "accepted": true + }, + { + "id": "type-int16-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "int16" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int16-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int16" + } + } + }, + "accepted": true + }, + { + "id": "type-int16-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "int16" + } + } + }, + "accepted": true + }, + { + "id": "type-int16-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "int16" + } + } + }, + "accepted": true + }, + { + "id": "type-int32-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int32" + } + } + }, + "accepted": true + }, + { + "id": "type-int32-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "int32" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int32-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int32" + } + } + }, + "accepted": true + }, + { + "id": "type-int32-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "int32" + } + } + }, + "accepted": true + }, + { + "id": "type-int32-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "int32" + } + } + }, + "accepted": true + }, + { + "id": "type-int64-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int64" + } + } + }, + "accepted": true + }, + { + "id": "type-int64-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "int64" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int64-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int64" + } + } + }, + "accepted": true + }, + { + "id": "type-int64-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "int64" + } + } + }, + "accepted": true + }, + { + "id": "type-int64-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "int64" + } + } + }, + "accepted": true + }, + { + "id": "type-int128-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "int128" + } + } + }, + "accepted": true + }, + { + "id": "type-int128-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "int128" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-int128-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int128" + } + } + }, + "accepted": true + }, + { + "id": "type-int128-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "int128" + } + } + }, + "accepted": true + }, + { + "id": "type-int128-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "int128" + } + } + }, + "accepted": true + }, + { + "id": "type-uint8-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "uint8" + } + } + }, + "accepted": true + }, + { + "id": "type-uint8-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "uint8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint8-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "uint8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint8-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "uint8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint8-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "uint8" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint16-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "uint16" + } + } + }, + "accepted": true + }, + { + "id": "type-uint16-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "uint16" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint16-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "uint16" + } + } + }, + "accepted": true + }, + { + "id": "type-uint16-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "uint16" + } + } + }, + "accepted": true + }, + { + "id": "type-uint16-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "uint16" + } + } + }, + "accepted": true + }, + { + "id": "type-uint32-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "uint32" + } + } + }, + "accepted": true + }, + { + "id": "type-uint32-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "uint32" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint32-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "uint32" + } + } + }, + "accepted": true + }, + { + "id": "type-uint32-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "uint32" + } + } + }, + "accepted": true + }, + { + "id": "type-uint32-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "uint32" + } + } + }, + "accepted": true + }, + { + "id": "type-uint64-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "uint64" + } + } + }, + "accepted": true + }, + { + "id": "type-uint64-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "uint64" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint64-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "uint64" + } + } + }, + "accepted": true + }, + { + "id": "type-uint64-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "uint64" + } + } + }, + "accepted": true + }, + { + "id": "type-uint64-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "uint64" + } + } + }, + "accepted": true + }, + { + "id": "type-uint128-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "uint128" + } + } + }, + "accepted": true + }, + { + "id": "type-uint128-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "uint128" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-uint128-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "uint128" + } + } + }, + "accepted": true + }, + { + "id": "type-uint128-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "uint128" + } + } + }, + "accepted": true + }, + { + "id": "type-uint128-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "uint128" + } + } + }, + "accepted": true + }, + { + "id": "type-float32-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "float32" + } + } + }, + "accepted": true + }, + { + "id": "type-float32-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "float32" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-float32-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "float32" + } + } + }, + "accepted": true + }, + { + "id": "type-float32-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "float32" + } + } + }, + "accepted": true + }, + { + "id": "type-float32-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "float32" + } + } + }, + "accepted": true + }, + { + "id": "type-float64-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "float64" + } + } + }, + "accepted": true + }, + { + "id": "type-float64-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "float64" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-float64-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "float64" + } + } + }, + "accepted": true + }, + { + "id": "type-float64-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "float64" + } + } + }, + "accepted": true + }, + { + "id": "type-float64-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "float64" + } + } + }, + "accepted": true + }, + { + "id": "type-decimal-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "decimal" + } + } + }, + "accepted": true + }, + { + "id": "type-decimal-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "decimal" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-decimal-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "decimal" + } + } + }, + "accepted": true + }, + { + "id": "type-decimal-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "decimal" + } + } + }, + "accepted": true + }, + { + "id": "type-decimal-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "decimal" + } + } + }, + "accepted": true + }, + { + "id": "type-date-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "date" + } + } + }, + "accepted": true + }, + { + "id": "type-date-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "date" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-date-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "date" + } + } + }, + "accepted": true + }, + { + "id": "type-date-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "date" + } + } + }, + "accepted": true + }, + { + "id": "type-date-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "date" + } + } + }, + "accepted": true + }, + { + "id": "type-timestamp-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "timestamp" + } + } + }, + "accepted": true + }, + { + "id": "type-timestamp-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "timestamp" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-timestamp-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "timestamp" + } + } + }, + "accepted": true + }, + { + "id": "type-timestamp-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "timestamp" + } + } + }, + "accepted": true + }, + { + "id": "type-timestamp-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "timestamp" + } + } + }, + "accepted": true + }, + { + "id": "type-string-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "string" + } + } + }, + "accepted": true + }, + { + "id": "type-string-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "string" + } + } + }, + "accepted": true + }, + { + "id": "type-string-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "string" + } + } + }, + "accepted": true + }, + { + "id": "type-string-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "string" + } + } + }, + "accepted": true + }, + { + "id": "type-string-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "string" + } + } + }, + "accepted": true + }, + { + "id": "type-bytes-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "bytes" + } + } + }, + "accepted": true + }, + { + "id": "type-bytes-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "bytes" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-bytes-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "bytes" + } + } + }, + "accepted": true + }, + { + "id": "type-bytes-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "bytes" + } + } + }, + "accepted": true + }, + { + "id": "type-bytes-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "bytes" + } + } + }, + "accepted": true + }, + { + "id": "type-array-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "array" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-array-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "array" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-array-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "array" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-array-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "array" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-array-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "array" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-object-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "object" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-object-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "object" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-object-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "object" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-object-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "object" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-object-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "object" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-json-eq", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "eq"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-json-match", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "match"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-json-ore", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-json-ope", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ope"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "type-json-ore_block", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore_block"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "index_not_admitted", "field": "value" } + } + }, + { + "id": "root-null", + "input": { "json": null }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + } + }, + { + "id": "root-list", + "input": { "json": [] }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + } + }, + { + "id": "empty-plan", + "input": { "json": {} }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "no_fields" } + } + }, + { + "id": "field-not-object", + "input": { "json": { "value": "x" } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object", "field": "value" } + } + }, + { + "id": "field-null", + "input": { "json": { "value": null } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object", "field": "value" } + } + }, + { + "id": "unknown-key", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "unknown": true + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_key", "field": "value" } + } + }, + { + "id": "missing-context", + "input": { "json": { "value": { "outputs": ["c"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "missing_context", "field": "value" } + } + }, + { + "id": "missing-outputs", + "input": { "json": { "value": { "context": ["users", "value"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "missing_outputs", "field": "value" } + } + }, + { + "id": "outputs-with-target", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "target": "TextEq" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "outputs_with_target", "field": "value" } + } + }, + { + "id": "outputs-string", + "input": { + "json": { "value": { "context": ["users", "value"], "outputs": "c" } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "outputs_not_list", "field": "value" } + } + }, + { + "id": "outputs-null", + "input": { + "json": { "value": { "context": ["users", "value"], "outputs": null } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "outputs_not_list", "field": "value" } + } + }, + { + "id": "outputs-empty", + "input": { + "json": { "value": { "context": ["users", "value"], "outputs": [] } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "no_outputs", "field": "value" } + } + }, + { + "id": "output-unknown", + "input": { + "json": { + "value": { "context": ["users", "value"], "outputs": ["wat"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "output-options-on-equality", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "eq": {} }], + "type": "int64" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "output-duplicate", + "input": { + "json": { + "value": { "context": ["users", "value"], "outputs": ["c", "c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "duplicate_output", "field": "value" } + } + }, + { + "id": "match-options-duplicate-key", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["match", { "match": {} }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "duplicate_output", "field": "value" } + } + }, + { + "id": "passthrough-with-ciphertext", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["passthrough", "c"] + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "passthrough_with_outputs", "field": "value" } + } + }, + { + "id": "type-unknown", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "type": "uuid" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_type", "field": "value" } + } + }, + { + "id": "type-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "type": null + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_type", "field": "value" } + } + }, + { + "id": "type-json-without-target", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "json_needs_target", "field": "value" } + } + }, + { + "id": "json-output-pending", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["json"], + "type": "json" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "json_index_pending", "field": "value" } + } + }, + { + "id": "context-bare-label", + "input": { + "json": { "value": { "context": "users/value", "outputs": ["c"] } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "value" } + } + }, + { + "id": "context-one-segment", + "input": { + "json": { "value": { "context": ["value"], "outputs": ["c"] } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "value" } + } + }, + { + "id": "context-nested-extension", + "input": { + "json": { + "value": { + "context": [["users", "value"], ["tenant"]], + "outputs": ["c"] + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "value" } + } + }, + { + "id": "context-segment-slash", + "input": { + "json": { "value": { "context": ["users", "a/b"], "outputs": ["c"] } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "value" } + } + }, + { + "id": "context-segment-digit", + "input": { + "json": { "value": { "context": ["users", "9"], "outputs": ["c"] } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "value" } + } + }, + { + "id": "mixed-prefix", + "input": { + "json": { + "a": { "context": ["users", "a"], "outputs": ["c"] }, + "b": { "context": ["orders", "b"], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "mixed_contexts", "field": "b" } + } + }, + { + "id": "mixed-extension", + "input": { + "json": { + "a": { "context": [["users", "a"], 1], "outputs": ["c"] }, + "b": { "context": [["users", "b"], 2], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "mixed_contexts", "field": "b" } + } + }, + { + "id": "shared-identity", + "input": { + "json": { + "a": { "context": ["users", "same"], "outputs": ["c"] }, + "b": { "context": ["users", "same"], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "shared_identity", "field": "b" } + } + }, + { + "id": "context-field-null", + "input": { + "json": { + "context_field": null, + "value": { "context": ["users", "value"], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_field" } + } + }, + { + "id": "context-field-not-declared", + "input": { + "json": { + "context_field": "tenant", + "value": { "context": ["value"], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "no_such_field", "field": "tenant" } + } + }, + { + "id": "context-field-encrypted", + "input": { + "json": { + "context_field": "tenant", + "tenant": { "context": ["tenant"], "outputs": ["c"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_field", "field": "tenant" } + } + }, + { + "id": "context-field-static-label", + "input": { + "json": { + "context_field": "tenant", + "tenant": { + "context": ["users", "tenant"], + "outputs": ["passthrough"] + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "context_not_label", "field": "tenant" } + } + }, + { + "id": "null-mode-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "null": null + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_null", "field": "value" } + } + }, + { + "id": "null-mode-unknown", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "null": "optional" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_null", "field": "value" } + } + }, + { + "id": "null-passthrough", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["passthrough"], + "null": "sql" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "null_passthrough", "field": "value" } + } + }, + { + "id": "sealed-null-untyped", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "null": "sealed" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "sealed_null_type", "field": "value" } + } + }, + { + "id": "sealed-null-text", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c"], + "type": "string", + "null": "sealed" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "sealed_null_type", "field": "value" } + } + }, + { + "id": "sealed-null-order", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": ["c", "ore"], + "type": "int64", + "null": "sealed" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "sealed_null_index", "field": "value" } + } + }, + { + "id": "target-null", + "input": { + "json": { "value": { "context": ["users", "value"], "target": null } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, + { + "id": "target-empty", + "input": { + "json": { "value": { "context": ["users", "value"], "target": "" } } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, + { + "id": "target-options-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "target": { "name": "TextEq", "options": null } + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, + { + "id": "target-options-unknown", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "target": { "name": "JsonSearch", "options": { "wat": 1 } } + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, + { + "id": "target-options-position", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "target": { + "name": "JsonSearch", + "options": { "ordered": ["$.a[0]"] } + } + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "invalid_target", "field": "value" } + } + }, + { + "id": "context-null", + "input": { "json": { "value": { "context": null, "outputs": ["c"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_context", + "fields": { "reason": "context_kind", "field": "value" } + } + }, + { + "id": "context-bool", + "input": { "json": { "value": { "context": true, "outputs": ["c"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_context", + "fields": { "reason": "context_kind", "field": "value" } + } + }, + { + "id": "context-float", + "input": { "json": { "value": { "context": 1.25, "outputs": ["c"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_context", + "fields": { "reason": "context_kind", "field": "value" } + } + }, + { + "id": "context-empty", + "input": { "json": { "value": { "context": [], "outputs": ["c"] } } }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_context", + "fields": { "reason": "empty_context", "field": "value" } + } + }, + { + "id": "untyped-index", + "input": { + "json": { + "value": { "context": ["users", "value"], "outputs": ["eq"] } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_untyped_index", + "fields": { "field": "value" } + } + }, + { + "id": "target-unavailable-name", + "input": { + "json": { + "value": { "context": ["users", "value"], "target": "TextEq" } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::target_none", + "fields": { "target": "TextEq" } + } + }, + { + "id": "target-unavailable-configured", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "target": { "name": "TextEq" } + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::target_none", + "fields": { "target": "TextEq" } + } + }, + { + "id": "target-unavailable-json-options", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "target": { + "name": "JsonSearch", + "options": { "sealed_root": false, "ordered": ["$.age", "$.age"] } + } + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::target_none", + "fields": { "target": "JsonSearch" } + } + }, + { + "id": "match-invalid-unknown", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "wat": 1 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-k-low", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "k": 2 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-k-high", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "k": 17 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-m-power", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "m": 100 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-m-low", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "m": 16 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-m-high", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "m": 131072 } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-ngram-zero", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "tokenizer": { "ngram": 0 } } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-short-standard", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [ + { "match": { "tokenizer": "standard", "short_words": true } } + ], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-short-small", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [ + { "match": { "tokenizer": { "ngram": 2 }, "short_words": true } } + ], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "match-invalid-downcase-null", + "input": { + "json": { + "value": { + "context": ["users", "value"], + "outputs": [{ "match": { "downcase": null } }], + "type": "string" + } + } + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "unknown_output", "field": "value" } + } + }, + { + "id": "typed-extension-int32", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f002000000f0020000000a0500000075736572730a0500000076616c75650407000000070000006f757470757473f0010000000a0100000063" + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [{ "type": "int32", "text": "7" }], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "typed-extension-int64", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f002000000f0020000000a0500000075736572730a0500000076616c7565050700000000000000070000006f757470757473f0010000000a0100000063" + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [{ "type": "int64", "text": "7" }], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "typed-extension-uint32", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f002000000f0020000000a0500000075736572730a0500000076616c75650607000000070000006f757470757473f0010000000a0100000063" + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [{ "type": "uint32", "text": "7" }], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "typed-extension-uint64", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f002000000f0020000000a0500000075736572730a0500000076616c7565070700000000000000070000006f757470757473f0010000000a0100000063" + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [{ "type": "uint64", "text": "7" }], + "fields": [ + { + "name": "value", + "label": ["users", "value"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + }, + { + "id": "typed-extension-int8", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f002000000f0020000000a0500000075736572730a0500000076616c75650c07070000006f757470757473f0010000000a0100000063" + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_context", + "fields": { "field": "value", "reason": "context_kind" } + } + }, + { + "id": "frame-empty", + "input": { "frame_hex": "" }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + }, + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + } + }, + { + "id": "frame-invalid", + "input": { "frame_hex": "ff" }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + }, + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + } + }, + { + "id": "frame-truncated", + "input": { "frame_hex": "f101" }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + }, + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + } + }, + { + "id": "frame-duplicate-key", + "input": { + "frame_hex": "f1020000000500000076616c7565f10200000007000000636f6e74657874f0020000000a0500000075736572730a0500000076616c7565070000006f757470757473f0010000000a01000000630500000076616c7565f10200000007000000636f6e74657874f0020000000a0500000075736572730a0500000076616c7565070000006f757470757473f0010000000a0100000063" + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + }, + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + } + }, + { + "id": "frame-trailing", + "input": { + "frame_hex": "f1010000000500000076616c7565f10200000007000000636f6e74657874f0020000000a0500000075736572730a0500000076616c7565070000006f757470757473f0010000000a010000006300" + }, + "accepted": false, + "expected_error": { + "code": "stack_encrypt::dynamic_plan", + "fields": { "reason": "not_an_object" } + }, + "boundary_error": { + "code": "stack_guest_abi::malformed_input", + "fields": {} + } + }, + { + "id": "frame-field-order", + "input": { + "frame_hex": "f102000000010000007af10200000007000000636f6e74657874f0020000000a0500000075736572730a010000007a070000006f757470757473f0010000000a01000000630100000061f10200000007000000636f6e74657874f0020000000a0500000075736572730a0100000061070000006f757470757473f0010000000a0100000063" + }, + "accepted": true, + "normalized": { + "label": ["users"], + "context_field": null, + "extension": [], + "fields": [ + { + "name": "z", + "label": ["users", "z"], + "type": null, + "null": null, + "outputs": ["c"] + }, + { + "name": "a", + "label": ["users", "a"], + "type": null, + "null": null, + "outputs": ["c"] + } + ] + } + } + ] +} diff --git a/conformance/stack-encrypt/v1/native.json b/conformance/stack-encrypt/v1/native.json new file mode 100644 index 000000000..a3d3ebdf6 --- /dev/null +++ b/conformance/stack-encrypt/v1/native.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "profile": "record-scalars-v1", + "producer": "rust-native/stack-encrypt-0.3.0", + "cases": { + "above-js-safe-integer": { + "frame": "050100000000002000", + "ciphertext": "0100000000000000000000000000000000d7f53b2f05f735b1a761ae2897e32cb9200080a7c97b8885087c0530fac2a364a5259fc2177478b543e972247f3af59a3a80020fe1a86efa34927b061b60bcac42a74c0a35e21e8e84c58973e29cd30edc21b9f017bca00c", + "equality": "ac6c5f70a992a8b5d835ceb5bfb459b728a94c220e5e0ffca3f858aa66db7db4" + }, + "i32-max": { + "frame": "04ffffff7f", + "ciphertext": "0100000000000000000000000000000000df8645b1e40e43f85ac7f5e826cdc74a20000c6e5a55e60affd88eb9de36776909b2a62f0647fafd4b7e3540ef8d957793530208f96d6eb25ce8eaee6dddf6f8f78015539889051e72eddd5d96456b5487231923", + "equality": "d679831312fc2b397f9b7fa5dba2ac3918431668c9923c379eadf8fedf4a7c4c" + }, + "i32-min": { + "frame": "0400000080", + "ciphertext": "01000000000000000000000000000000003b55fcd86eac559b36435b57fb2d00d62000956e7ffef9fa6b6f0c58944a4df74aeec52dd16c166b567014a3eaab79ff63a802fefc11d37697e01980445e5adef82ec75c3840346dacca3e359e197c91ccfeeb8a", + "equality": "e9b219761c0e549e3b5540744af1ceeebe241b6ed739e0f463d1c4af6d00eb7f" + }, + "i64-max": { + "frame": "05ffffffffffffff7f", + "ciphertext": "01000000000000000000000000000000001689636dabf977a1d7fce31594bfa15420003414223db76e9c96ce9603416a47e602911f24c04522e5dcbf303b3b10c2db160296dd082dbdf1bce3876b148fcf2d28f1753c3e2c93c4ea6afc146c23fdc9434e9d770ad716", + "equality": "b3ea26e6833e7665ef374b18f7123b0a795fbeea7f1696f3e3b63971f3201bcb" + }, + "i64-min": { + "frame": "050000000000000080", + "ciphertext": "01000000000000000000000000000000005fe9f26245bf484c734b5299be73d83a200051ae2d51b5d42745b2a586e663829d635deeed00a5408cf6e427b74d6213b9de0240237777e11809dbe6f3e3b857440c2f7cba391c95284d5269daf03de849ba7563ed296ec1", + "equality": "f504a4593ee7972726e6bd6420e11d5bc30c5d36285b2029df394a1d373f76db" + }, + "text-empty": { + "frame": "0a00000000", + "ciphertext": "010000000000000000000000000000000079a06bc059ef87dec9f58a8876c0112f20005d48c836a336273250cd8150593c25f4be53b49e3ae8bfe4e0d007cb0aa720d30259e10ebabb074b442c43bd77aa42dbab6b369f7e2725e656f936480796", + "equality": "cdf6de2f0cae47435a2a3fcd8d0ec5e29670302f65567a746555507def9aad52" + }, + "text-unicode": { + "frame": "0a11000000636166c3a920e69db1e4baac20f09f9490", + "ciphertext": "0100000000000000000000000000000000aba3b145ab9dddbcf4d5424461fd45c32000ef3f0cd3daac23097d97a85e8405923e9d95d29436c6ef5d848dee41ceeca24e0298a6c8ff53f67ff24459e4db609400f9f0f2c4f5c0818fafe2aecee06aba4e9500aceb4ec3c5c13d2bfeb335d766", + "equality": "d44828c3c9a883bd802b0c3fb3728f240bbdc244095ce71f39e430a40d7e4b07" + }, + "u64-max": { + "frame": "07ffffffffffffffff", + "ciphertext": "01000000000000000000000000000000001aaf730b6c6bc77770144918b59a35932000ef235e515a8e50ff41f3a26ba62e5de76f5331373f245ced6bce3a5638acffd302e975cc6e364604b6b1c0563752772c462fc353b94531ddca1436504262400183186ff2f004", + "equality": "04c8d7f02d9555152bdd770106eab974e517a0eba6b339e3ad1b0108796359d8" + } + } +} diff --git a/docs/plans/2026-10-31-stack-encrypt-conformance.md b/docs/plans/2026-10-31-stack-encrypt-conformance.md new file mode 100644 index 000000000..e17849a79 --- /dev/null +++ b/docs/plans/2026-10-31-stack-encrypt-conformance.md @@ -0,0 +1,142 @@ +# Stack Encrypt cross-language conformance + +Date: 2026-10-31. Status: scalar and declaration 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 +[SDK principles](../sdk-design-principles.md) and the interoperability deliverable +in [the API redesign](https://github.com/cipherstash/stack/pull/1365). + +## Objective + +Equivalent declarations and operations have the same meaning in native Rust, +Go, Python, and TypeScript. SDKs retain idiomatic APIs and find errors at the +earliest stage their languages permit. Encryption and semantic validation stay +in the shared Rust engine. + +Python bindings are underway; TypeScript bindings are planned. Neither runner +is present in this checkout. Do not report their conformance as passing or +assume they will use Go's WASI backend. Native and WASM backends implement the +same semantic contract and have additional lifecycle tests of their own. + +Vitamin C's transport and storage codecs are dependencies of this contract. +Reference their versions and vectors rather than copying their specifications. +Codec agreement alone does not prove declaration or operation agreement. + +## Approach and order + +1. **Define the layers.** Specify value representation, declarations, operations, + persistence/SQL, binding lifecycle, and language SDK mappings separately. + Keep public SDK syntax distinct from the engine's data grammar and from the + test corpus format. +2. **Inventory the implemented profile.** Map existing tests to requirements, + identifying covered, partial, and missing evidence. Document current `next` + behavior and explicit differences from plaintext SQL. Proposed context roles + and future declarations must not silently become requirements of this profile. +3. **Create one portable corpus.** Give cases stable IDs, required capabilities, + typed inputs, declarations, operations, and expected values or diagnostics. + Use text for integers and other values JSON cannot carry losslessly. Run the + same cases in Rust and Go first; enroll Python early and TypeScript when its + binding exists. Unknown versions, operations, or required cases must fail. +4. **Test SDK mappings separately.** Exercise real host values and public SDK + declarations, not just pre-encoded frames. Cover TypeScript number/bigint, + undefined/null, byte arrays and timestamps; Python integer ranges, bool/int, + bytes/text, decimals and timezones; Go integer widths, pointers and typed nil. + Make unsupported or lossy conversions explicit refusals. +5. **Use independent evidence.** Combine fixed vectors, cross-language writes + and reads, plaintext SQL oracles, malformed requests and tampering. Compare + randomized ciphertext by opening it and querying it, not by byte equality. + Instrument test key providers for batching and validation-before-I/O checks. + Verify cleanup, cancellation and ownership separately for each backend; + do not promise equivalent erasure of host-language values. +6. **Gate claims in CI.** Record corpus/profile, SDK, engine, backend and supported + capabilities. Every required case must run; an absent guest or unknown case + must not create a green conformance result. Keep a fast credential-free lane, + with database, live-provider and cross-version lanes alongside it. Version + expected behavior and require review for changes to frozen expectations. + +## First slice + +The initial `record-scalars-v1` test profile is intentionally small. It covers +typed scalar boundaries, a one-field record declaration, exact equality terms, +cross-opening native scalar leaves through the Go record path, wrong-context +opening, and rejected query inputs. It uses the existing deterministic test +provider, with public test keys only. + +The Rust runner exercises both the owned scalar API from #1404 and dynamic +record/query execution. The Go runner exercises the internal record interface +used by generated SDK code. This proves engine/binding behavior; it does not +replace tests of generated declarations or certify the complete Go SDK. + +The shared corpus and runner contract live in +[`conformance/stack-encrypt`](../../conformance/stack-encrypt/README.md). +The coverage map there identifies existing evidence and remaining gaps. + +## First-slice checkpoint + +Implemented on `feat/stack-encrypt-conformance`, based directly on #1404. +The corpus has eight positive cases and three query refusals. Local validation +passes for native Rust, Go WASI, and fresh Go-to-Rust artifact exchange through +`mise run test:conformance`. Rust clippy, the enabled Go linters, and 33 CI wiring +checks also pass. The work is in draft [PR #1460](https://github.com/cipherstash/stack/pull/1460), +targeting #1404's branch. Local validation is distinct from hosted CI status. + +The first profile includes frozen typed frames and equality terms, frozen native +ciphertexts, fresh native scalar and record round trips, and forbidden retrieval +under a wrong context. The implementation adds no production API changes. +SQL requirements and Python/TypeScript runners remain open. + +## Declaration checkpoint + +The [declaration grammar](../../conformance/stack-encrypt/declaration-grammar.md) +records the implemented syntax and semantic rules, including accepted enum +aliases, defaults, context composition, all 21 kinds against five scalar indexes, +null policies and target-capability refusal. Its 201 cases run in native Rust +and Go WASI without a cipher. Native Rust checks selected normalized plan +observations; Go's existing checker exposes acceptance and diagnostics only. + +The profile fixes `NoTargets`. EQL resolver/catalog behavior and full JSON/SQL +operation semantics remain later profiles. Malformed frames explicitly distinguish +the native parser's diagnostic from the WASI boundary's earlier refusal. + +Local validation passes for both profiles (212 shared cases), fresh ciphertext +exchange, Rust clippy/formatting, Go lint, Biome and 34 CI wiring checks. + +The [coverage map](../../conformance/stack-encrypt/coverage.md#consolidation-policy) +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. + +## Follow-up slices + +- Extend the declaration grammar to EQL resolver capabilities and the operation + request profiles. Generate structural schemas from shared models where faithful; + keep semantic validation executable rather than assuming JSON Schema covers it. +- Extend shared cases to runtime source/read/request shapes, missing/null values, + all numeric kinds, and context binding. Consolidate existing duplicate tables + only after their unique assertions have equivalent coverage. +- Bring the existing JSON/SQL suites under named requirements. Add common JSON + vectors across runners and pin the supported predicate grammar and its limits. +- Add Python's runner in its owning repository/worktree, with a declared capability + set and the same artifact exchange. Establish its actual backend and APIs with + that implementation; do not design them by inference here. +- Add TypeScript's runner and SDK mapping tests when its binding exists. Include + native and WASM variants if both are supported. +- Extend artifact exchange to every producer/consumer pair for common profiles, + then supported release versions. Pin SQL bundle and engine versions in reports. +- Add backend lifecycle, cancellation, no-secret-diagnostics, key-I/O and batching + requirements. Reuse current tests and instrument providers where evidence is absent. + +## Completion criteria + +A profile is conformant only when every mandatory requirement has executable +evidence for the claimed language/backend/version. Optional capabilities have +named tests and explicit support status. No unsupported feature is silently +ignored. Released profiles define supported version combinations and evolution +rules; the corpus format version is not a new production wire version. + +Initial success means Rust and Go run the same cases, freshly written Go values +open in native Rust, fixed native values open in Go, and malformed query inputs +produce the specified diagnostics. Python and TypeScript remain visibly pending +until their runners execute. The complete specification, SQL profile, and all +language SDK mappings remain follow-up work. diff --git a/languages/golang/encrypt/conformance_test.go b/languages/golang/encrypt/conformance_test.go new file mode 100644 index 000000000..ea74d6f3d --- /dev/null +++ b/languages/golang/encrypt/conformance_test.go @@ -0,0 +1,240 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/internal/record" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" +) + +// This runner tests the record interface used by generated SDK code. Generated +// declarations have separate tests; this is not a substitute for SDK conformance. +const conformanceProfile = "record-scalars-v1" + +type conformanceCorpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + Seed string `json:"seed"` + Context []string `json:"context"` + Field string `json:"field"` + RequiredCapabilities []string `json:"required_capabilities"` + Cases []conformanceCase `json:"cases"` +} + +type conformanceCase struct { + ID string `json:"id"` + Kind string `json:"kind"` + Input struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"input"` + ExpectedError *struct { + Code string `json:"code"` + Reason string `json:"reason"` + } `json:"expected_error"` +} + +type conformanceArtifact struct { + Frame string `json:"frame"` + Ciphertext string `json:"ciphertext"` + Equality string `json:"equality"` +} + +type conformanceArtifacts struct { + Version int `json:"version"` + Profile string `json:"profile"` + Producer string `json:"producer"` + Cases map[string]conformanceArtifact `json:"cases"` +} + +func readConformanceJSON(t *testing.T, path string, out any) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + d := json.NewDecoder(bytes.NewReader(data)) + d.DisallowUnknownFields() + if err := d.Decode(out); err != nil { + t.Fatal(err) + } + if err := d.Decode(new(any)); err != io.EOF { + t.Fatalf("%s: trailing JSON or invalid suffix: %v", path, err) + } +} + +func conformanceValue(t *testing.T, c conformanceCase) any { + t.Helper() + switch c.Input.Type { + case "int32", "int64": + bits := 64 + if c.Input.Type == "int32" { + bits = 32 + } + n, err := strconv.ParseInt(c.Input.Text, 10, bits) + if err != nil { + t.Fatal(err) + } + if bits == 32 { + return int32(n) + } + return n + case "uint64": + n, err := strconv.ParseUint(c.Input.Text, 10, 64) + if err != nil { + t.Fatal(err) + } + return n + case "bool": + if c.Input.Text != "true" && c.Input.Text != "false" { + t.Fatal("bool must be true or false") + } + return c.Input.Text == "true" + case "string": + return c.Input.Text + case "null": + if c.Input.Text != "" { + t.Fatal("null has empty text") + } + return nil + default: + t.Fatalf("unsupported input type %q", c.Input.Type) + return nil + } +} + +func TestStackConformance(t *testing.T) { + root := filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1") + var corpus conformanceCorpus + readConformanceJSON(t, filepath.Join(root, "cases.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != conformanceProfile || len(corpus.Cases) == 0 { + t.Fatal("unsupported or empty conformance corpus") + } + if !reflect.DeepEqual(corpus.RequiredCapabilities, []string{"records", "equality", "context-binding", "query-errors"}) { + t.Fatal("unsupported required capabilities") + } + seen := make(map[string]bool) + positive := make(map[string]bool) + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or duplicate case ID %q", c.ID) + } + seen[c.ID] = true + if c.ExpectedError == nil { + positive[c.ID] = true + } + } + var native conformanceArtifacts + readConformanceJSON(t, filepath.Join(root, "native.json"), &native) + if native.Version != 1 || native.Profile != conformanceProfile || native.Producer == "" || len(native.Cases) != len(positive) || len(positive) == 0 { + t.Fatal("artifact profile or case set differs from the corpus") + } + for id := range native.Cases { + if !positive[id] { + t.Fatalf("unexpected artifact %q", id) + } + } + seed, err := hex.DecodeString(corpus.Seed) + if err != nil || len(seed) != 32 { + t.Fatal("expected 32-byte deterministic test seed") + } + ctx := context.Background() + client, err := encrypt.NewDeterministicClient(ctx, [32]byte(seed)) + 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() + output := conformanceArtifacts{Version: 1, Profile: conformanceProfile, Producer: "go-wasi", Cases: make(map[string]conformanceArtifact)} + for _, c := range corpus.Cases { + t.Run(c.ID, func(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) + 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()) + } + return + } + if c.Kind != c.Input.Type { + t.Fatal("positive case must use its declared type") + } + if err != nil { + t.Fatal(err) + } + if query.Operator != "eq" || query.Index != record.Equality || query.Fidelity != record.Exact || query.Encoding != "bytes" || query.Domain != "" || len(query.Terms) != 1 { + t.Fatal("unexpected equality operand metadata") + } + want := native.Cases[c.ID] + encoded, err := vcffi.Marshal(input) + if err != nil { + t.Fatal(err) + } + if hex.EncodeToString(encoded) != want.Frame || hex.EncodeToString(query.Terms[0]) != want.Equality { + t.Fatal("typed frame or equality bytes disagree with the native vector") + } + decode := func(text string) []byte { + t.Helper() + b, err := hex.DecodeString(text) + if err != nil { + t.Fatal(err) + } + return b + } + source := []record.Source{{corpus.Field: input}} + fresh, err := cipher.Seal(ctx, plan, source) + if err != nil { + t.Fatal(err) + } + if len(fresh) != 1 || !bytes.Equal(fresh[0][corpus.Field].Terms[record.Equality], query.Terms[0]) { + t.Fatal("stored equality differs from query equality") + } + frozen := []record.Sealed{{corpus.Field: {Ciphertext: decode(want.Ciphertext), Terms: map[record.Output][]byte{record.Equality: decode(want.Equality)}}}} + for name, rows := range map[string][]record.Sealed{"native": frozen, "go": fresh} { + opened, err := cipher.Open(ctx, plan, rows) + if err != nil || !reflect.DeepEqual(opened, source) { + t.Fatalf("%s cross-open: err=%v; typed value mismatch=%v", name, err, !reflect.DeepEqual(opened, source)) + } + } + wrong := *plan + wrong.Context = append(append([]string{}, corpus.Context...), "wrong") + if _, err := cipher.Open(ctx, &wrong, frozen); !errors.Is(err, encrypt.ErrForbidden) { + t.Fatalf("wrong context: expected forbidden key retrieval, got %v", err) + } + output.Cases[c.ID] = conformanceArtifact{Frame: want.Frame, Ciphertext: hex.EncodeToString(fresh[0][corpus.Field].Ciphertext), Equality: hex.EncodeToString(query.Terms[0])} + }) + } + if t.Failed() { + return + } + if path := os.Getenv("STACK_CONFORMANCE_OUTPUT"); path != "" { + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatal(err) + } + } +} diff --git a/languages/golang/encrypt/declaration_conformance_test.go b/languages/golang/encrypt/declaration_conformance_test.go new file mode 100644 index 000000000..4e1dafc15 --- /dev/null +++ b/languages/golang/encrypt/declaration_conformance_test.go @@ -0,0 +1,167 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "strconv" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/vitaminc/bindings/go/vcffi" + "github.com/cipherstash/vitaminc/bindings/go/vcvalue" +) + +type declarationCase struct { + ID string `json:"id"` + Input struct { + JSON json.RawMessage `json:"json"` + FrameHex *string `json:"frame_hex"` + } `json:"input"` + Accepted *bool `json:"accepted"` + ExpectedError *declarationError `json:"expected_error"` + BoundaryError *declarationError `json:"boundary_error"` + // Only native Rust exposes the parsed plan. Go checks acceptance and the + // ABI diagnostic; it makes no claim to observe normalization independently. + Normalized json.RawMessage `json:"normalized"` +} + +type declarationError struct { + Code string `json:"code"` + Fields map[string]any `json:"fields"` +} + +// The corpus JSON projection fixes object order lexically, positive integers +// as uint64 and negative integers as int64. This is test input conversion, +// not SDK normalization. Non-JSON transport shapes use frame_hex instead. +func declarationValue(t *testing.T, value any) any { + t.Helper() + switch v := value.(type) { + case map[string]any: + keys := make([]string, 0, len(v)) + for key := range v { + keys = append(keys, key) + } + sort.Strings(keys) + object := make(vcvalue.Object, 0, len(keys)) + for _, key := range keys { + object = append(object, vcvalue.Field{Key: key, Value: declarationValue(t, v[key])}) + } + return object + case []any: + for i := range v { + v[i] = declarationValue(t, v[i]) + } + return v + case json.Number: + text := v.String() + var value any + var err error + switch { + case strings.ContainsAny(text, ".eE"): + value, err = strconv.ParseFloat(text, 64) + case strings.HasPrefix(text, "-"): + value, err = strconv.ParseInt(text, 10, 64) + default: + value, err = strconv.ParseUint(text, 10, 64) + } + if err != nil { + t.Fatal(err) + } + return value + default: + return v + } +} + +func TestDeclarationConformance(t *testing.T) { + var corpus struct { + Version int `json:"version"` + Profile string `json:"profile"` + Resolver string `json:"resolver"` + Cases []declarationCase `json:"cases"` + } + readConformanceJSON(t, filepath.Join("..", "..", "..", "conformance", "stack-encrypt", "v1", "declarations.json"), &corpus) + if corpus.Version != 1 || corpus.Profile != "record-declarations-v1" || corpus.Resolver != "none" || len(corpus.Cases) == 0 { + t.Fatal("unsupported or empty declaration corpus") + } + wasm, err := os.ReadFile("testdata/stack_encrypt_guest_deterministic.wasm") + if errors.Is(err, os.ErrNotExist) && os.Getenv("STACK_CONFORMANCE_REQUIRED") != "1" { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + checker, err := encrypt.NewCheckerOver(ctx, wasm) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = checker.Close() }) + seen := map[string]bool{} + for _, c := range corpus.Cases { + if c.ID == "" || seen[c.ID] { + t.Fatalf("empty or duplicate case ID %q", c.ID) + } + seen[c.ID] = true + t.Run(c.ID, func(t *testing.T) { + if c.Accepted == nil { + t.Fatal("missing accepted outcome") + } + accepted := *c.Accepted + if accepted != (c.ExpectedError == nil) || (!accepted && len(c.Normalized) != 0) { + t.Fatal("contradictory expected outcome") + } + if (len(c.Input.JSON) == 0) == (c.Input.FrameHex == nil) { + t.Fatal("expected exactly one input representation") + } + var frame []byte + var err error + if c.Input.FrameHex != nil { + frame, err = hex.DecodeString(*c.Input.FrameHex) + } else { + decoder := json.NewDecoder(bytes.NewReader(c.Input.JSON)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + t.Fatal(err) + } + frame, err = vcffi.Marshal(declarationValue(t, value)) + } + if err != nil { + t.Fatal(err) + } + err = checker.CheckFrameForTest(ctx, frame) + expected := c.ExpectedError + if c.BoundaryError != nil { + if accepted || c.Input.FrameHex == nil { + t.Fatal("boundary refusal requires a rejected raw frame") + } + if _, decodeErr := vcffi.Unmarshal(frame); decodeErr == nil { + t.Fatal("boundary refusal requires a malformed frame") + } + expected = c.BoundaryError + } + if accepted { + if err != nil { + t.Fatal(err) + } + return + } + 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 != expected.Code || !reflect.DeepEqual(diagnostic.Fields, expected.Fields) { + t.Fatalf("got %s %#v; expected %s %#v", diagnostic.Code, diagnostic.Fields, expected.Code, expected.Fields) + } + }) + } +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index 753d1a196..b6ffdc710 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -183,3 +183,13 @@ var ( WantDiagnostic = wantDiagnostic WantCode = wantCode ) + +// CheckFrameForTest reaches the engine parser without host Plan validation or +// lowering, so the shared grammar can include malformed declarations. The +// checker has no cipher, credentials or usable transport. +func (k *Checker) CheckFrameForTest(ctx context.Context, encoded []byte) error { + _, err := k.c.call(ctx, func(ctx context.Context, inst *instance) ([]byte, error) { + return inst.call(ctx, inst.planCheck, buf(encoded)) + }) + return err +} diff --git a/mise.toml b/mise.toml index 1164e8e88..9e174844d 100644 --- a/mise.toml +++ b/mise.toml @@ -40,6 +40,10 @@ golangci-lint = "2.14.0" # short name in mise's registry). "aqua:wasm-bindgen/wasm-pack" = "0.13.1" +[tasks."test:conformance"] +description = "Run shared Rust/Go cases (build the deterministic guest first)" +run = "bash scripts/stack-conformance.sh" + [tasks."test:doc"] description = "Run documentation tests" run = "mise x --env test -- cargo test --doc --all-features --workspace" diff --git a/packages/stack-encrypt/tests/conformance.rs b/packages/stack-encrypt/tests/conformance.rs new file mode 100644 index 000000000..e7e9b1d5a --- /dev/null +++ b/packages/stack-encrypt/tests/conformance.rs @@ -0,0 +1,406 @@ +//! The language-neutral record-scalars-v1 profile. See conformance/stack-encrypt. +#![cfg(feature = "dynamic")] + +mod common; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; +use std::sync::atomic::Ordering; + +use common::frames::{frame, object}; +use miette::Diagnostic; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use stack_encrypt::dynamic::{ + self, record, Fidelity, NoTargets, OpSpec, Operand, Scope, ValueFrame, +}; +use stack_encrypt::target::IndexSpec; +use stack_encrypt::{ + CipherText, Ciphertext, Error, KeysetCipher, Label, SealedValue, StackCipherText, +}; + +const PROFILE: &str = "record-scalars-v1"; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + seed: String, + context: Vec, + field: String, + required_capabilities: Vec, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + kind: String, + input: Input, + 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 { + code: String, + reason: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Artifacts { + version: u32, + profile: String, + producer: String, + cases: BTreeMap, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Artifact { + frame: String, + ciphertext: String, + equality: String, +} + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../conformance/stack-encrypt/v1") +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn unhex(text: &str) -> Vec { + assert!( + text.is_ascii() && text.len().is_multiple_of(2), + "invalid hex" + ); + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).expect("hex byte")) + .collect() +} + +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()); + let declaration = json!({corpus.field.clone(): { + "context": context, "outputs": ["c", "eq"], "type": case.kind, + }}); + record::plan(&vitaminc_aead_frame::serde::to_vec(&declaration).unwrap()).unwrap() +} + +fn stored(corpus: &Corpus, artifact: &Artifact) -> StackCipherText { + CipherText::Map(vec![( + corpus.field.clone(), + CipherText::Map(vec![ + ( + "c".into(), + CipherText::Single(SealedValue::from_bytes(&unhex(&artifact.ciphertext)).unwrap()), + ), + ( + "eq".into(), + CipherText::Passthrough(common::passthrough(unhex(&artifact.equality))), + ), + ]), + )]) +} + +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>, + input: &Input, + context: &Label, +) -> Ciphertext { + macro_rules! seal { + ($value:expr) => { + keyset + .encrypt_with_context($value, context.clone()) + .await + .unwrap() + }; + } + match input.kind.as_str() { + "int32" => seal!(input.text.parse::().unwrap()), + "int64" => seal!(input.text.parse::().unwrap()), + "uint64" => seal!(input.text.parse::().unwrap()), + "bool" => seal!(input.text.parse::().unwrap()), + "string" => seal!(input.text.clone()), + other => panic!("unsupported scalar {other}"), + } +} + +async fn open_scalar( + keyset: &KeysetCipher<'_, common::DeterministicSource>, + bytes: &[u8], + kind: &str, + context: &Label, +) -> ValueFrame { + let ct = Ciphertext::from_bytes(bytes).unwrap(); + macro_rules! open { + ($ty:ty) => { + frame( + keyset + .decrypt_with_context::<$ty>(ct, context.clone()) + .await + .unwrap(), + ) + }; + } + match kind { + "int32" => open!(i32), + "int64" => open!(i64), + "uint64" => open!(u64), + "bool" => open!(bool), + "string" => open!(String), + other => panic!("unsupported scalar {other}"), + } +} + +#[tokio::test] +async fn shared_record_scalar_profile() { + let corpus: Corpus = + serde_json::from_slice(&std::fs::read(root().join("cases.json")).unwrap()).unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, PROFILE); + assert_eq!( + corpus.required_capabilities, + ["records", "equality", "context-binding", "query-errors"] + ); + assert!(!corpus.cases.is_empty()); + let ids: BTreeSet<_> = corpus.cases.iter().map(|c| c.id.as_str()).collect(); + assert_eq!(ids.len(), corpus.cases.len(), "duplicate case IDs"); + assert!(!ids.contains("")); + let positive_ids: BTreeSet<_> = corpus + .cases + .iter() + .filter(|c| c.expected_error.is_none()) + .map(|c| c.id.as_str()) + .collect(); + assert!(!positive_ids.is_empty()); + + let update = std::env::var("STACK_CONFORMANCE_UPDATE").as_deref() == Ok("1"); + let imported = std::env::var_os("STACK_CONFORMANCE_INPUT"); + assert!( + !(update && imported.is_some()), + "cannot update from external artifacts" + ); + let baseline: Option = if update { + None + } else { + let file = imported + .map(PathBuf::from) + .unwrap_or_else(|| root().join("native.json")); + let artifacts: Artifacts = serde_json::from_slice(&std::fs::read(file).unwrap()).unwrap(); + assert_eq!(artifacts.version, 1); + assert_eq!(artifacts.profile, PROFILE); + assert!(!artifacts.producer.is_empty()); + assert_eq!( + artifacts + .cases + .keys() + .map(String::as_str) + .collect::>(), + positive_ids + ); + Some(artifacts) + }; + + let cipher = + common::deterministic_cipher(unhex(&corpus.seed).try_into().expect("32-byte seed")).await; + let keyset = cipher.default_keyset(); + let context = Label::new(corpus.context.iter().chain(std::iter::once(&corpus.field))).unwrap(); + let (counting, generates, retrieves) = common::counting_cipher().await; + let mut output = Artifacts { + version: 1, + profile: PROFILE.into(), + producer: format!("rust-native/stack-encrypt-{}", env!("CARGO_PKG_VERSION")), + cases: BTreeMap::new(), + }; + + for case in &corpus.cases { + eprintln!("conformance: {}", case.id); + let plan = plan(&corpus, case); + if let Some(expected) = &case.expected_error { + let scope = counting.default_keyset(); + let error = match dynamic::query_term( + &scope, + &plan, + &corpus.field, + OpSpec::Eq, + value(&case.input), + &NoTargets, + ) { + 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())); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + continue; + } + assert_eq!(case.kind, case.input.kind); + let input = value(&case.input); + let expected_row = object(vec![(corpus.field.clone(), input.clone())]); + let fresh = record::encrypt(&keyset, expected_row.clone(), &plan) + .unwrap() + .await + .unwrap(); + let stored_term = stored_equality(&fresh, &corpus.field); + let opened = record::decrypt(Scope::Keyset(keyset.clone()), fresh, &plan, None) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened.risky_ref(), + expected_row.risky_ref(), + "{}: record roundtrip", + case.id + ); + + let term = dynamic::query_term( + &keyset, + &plan, + &corpus.field, + OpSpec::Eq, + input.clone(), + &NoTargets, + ) + .unwrap() + .await + .unwrap(); + assert_eq!(term.operator(), OpSpec::Eq); + assert_eq!(term.index(), &IndexSpec::Equality); + assert_eq!(term.fidelity(), Fidelity::Exact); + assert_eq!(term.encoding(), "bytes"); + assert_eq!(term.domain(), None); + let Operand::One(term) = term.operand() else { + panic!("expected one equality term") + }; + assert_eq!(&stored_term, term, "{}: stored equality", case.id); + let ct = scalar(&keyset, &case.input, &context).await; + let artifact = Artifact { + frame: hex(input.risky_ref()), + ciphertext: hex(&ct.to_bytes()), + equality: hex(term), + }; + let reference = baseline.as_ref().map_or(&artifact, |a| &a.cases[&case.id]); + assert_eq!(artifact.frame, reference.frame, "{}: typed frame", case.id); + assert_eq!( + artifact.equality, reference.equality, + "{}: equality bytes", + case.id + ); + for (origin, candidate) in [("fresh", &artifact), ("reference", reference)] { + let opened = + open_scalar(&keyset, &unhex(&candidate.ciphertext), &case.kind, &context).await; + assert_eq!( + opened.risky_ref(), + input.risky_ref(), + "{}: {origin} scalar cross-open", + case.id + ); + let opened = record::decrypt( + Scope::Keyset(keyset.clone()), + stored(&corpus, candidate), + &plan, + None, + ) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened.risky_ref(), + expected_row.risky_ref(), + "{}: {origin} record cross-open", + case.id + ); + } + + let mut wrong = corpus.context.clone(); + wrong.push("wrong".into()); + wrong.push(corpus.field.clone()); + let wrong = Label::new(wrong).unwrap(); + let result = keyset + .decrypt_tree::( + CipherText::Single(SealedValue::from_bytes(&unhex(&reference.ciphertext)).unwrap()), + wrong, + ) + .await; + assert!( + matches!(result, Err(Error::Kms(stack_kms::Error::RetrieveKey( + stack_kms::RetrieveKeyError::RequestFailed(ref error) + ))) if matches!(error.kind, zerokms_protocol::ViturRequestErrorKind::Forbidden)), + "{}: wrong context: {result:?}", + case.id + ); + output.cases.insert(case.id.clone(), artifact); + } + if update { + std::fs::write( + root().join("native.json"), + serde_json::to_string_pretty(&output).unwrap() + "\n", + ) + .unwrap(); + } + if let Some(path) = std::env::var_os("STACK_CONFORMANCE_OUTPUT") { + std::fs::write(path, serde_json::to_string_pretty(&output).unwrap() + "\n").unwrap(); + } +} diff --git a/packages/stack-encrypt/tests/declarations.rs b/packages/stack-encrypt/tests/declarations.rs new file mode 100644 index 000000000..603ca3c2b --- /dev/null +++ b/packages/stack-encrypt/tests/declarations.rs @@ -0,0 +1,167 @@ +//! Executable acceptance and normalization contract for record declarations. +#![cfg(feature = "dynamic")] + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use miette::Diagnostic; +use serde::Deserialize; +use serde_json::{json, Value}; +use stack_encrypt::dynamic::record::{self, NullMode, Output}; +use stack_encrypt::{ContextPiece, ErrorPayload}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Corpus { + version: u32, + profile: String, + resolver: String, + cases: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Case { + id: String, + input: Input, + accepted: bool, + expected_error: Option, + boundary_error: Option, + normalized: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum Input { + Json(Value), + FrameHex(String), +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedError { + code: String, + fields: serde_json::Map, +} + +fn unhex(text: &str) -> Vec { + assert!(text.is_ascii() && text.len().is_multiple_of(2)); + (0..text.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&text[i..i + 2], 16).unwrap()) + .collect() +} + +fn extension(piece: &ContextPiece<'_>) -> Value { + let (kind, text) = match piece { + ContextPiece::Text(text) => ("string", text.to_string()), + ContextPiece::Bytes(bytes) => ("bytes", bytes.iter().map(|b| format!("{b:02x}")).collect()), + ContextPiece::I32(n) => ("int32", n.to_string()), + ContextPiece::I64(n) => ("int64", n.to_string()), + ContextPiece::U32(n) => ("uint32", n.to_string()), + ContextPiece::U64(n) => ("uint64", n.to_string()), + other => panic!("unexpected extension {other:?}"), + }; + json!({"type": kind, "text": text}) +} + +// An observation of the parsed engine plan, not a second declaration parser. +// No sorting: field/output order is observable and must survive parsing. +fn normalized(plan: &record::Plan) -> Value { + let fields: Vec<_> = plan.fields().iter().map(|field| { + let outputs: Vec<_> = field.outputs().iter().map(|output| match output { + Output::Ciphertext => json!("c"), + Output::Passthrough => json!("passthrough"), + Output::Term(index) => serde_json::to_value(index).unwrap(), + }).collect(); + json!({ + "name": field.name(), + "label": field.label().segments().collect::>(), + "type": field.field_type().map(|kind| kind.name()), + "null": field.null().map(|mode| match mode { NullMode::Sql => "sql", NullMode::Sealed => "sealed" }), + "outputs": outputs, + }) + }).collect(); + json!({ + "label": plan.label().map(|label| label.segments().collect::>()), + "context_field": plan.context_field(), + "extension": plan.extension().iter().map(extension).collect::>(), + "fields": fields, + }) +} + +#[test] +fn shared_declaration_grammar() { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../conformance/stack-encrypt/v1/declarations.json"); + let corpus: Corpus = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(corpus.version, 1); + assert_eq!(corpus.profile, "record-declarations-v1"); + assert_eq!(corpus.resolver, "none"); + assert!(!corpus.cases.is_empty()); + let mut ids = BTreeSet::new(); + let mut failures = Vec::new(); + for case in corpus.cases { + assert!( + !case.id.is_empty() && ids.insert(case.id.clone()), + "duplicate/empty ID" + ); + assert_eq!( + case.accepted, + case.expected_error.is_none(), + "{}: contradictory outcome", + case.id + ); + assert!( + case.accepted || case.normalized.is_none(), + "{}: rejected normalization", + case.id + ); + let frame = match case.input { + Input::Json(value) => vitaminc_aead_frame::serde::to_vec(&value).unwrap(), + Input::FrameHex(text) => unhex(&text), + }; + if let Some(boundary) = case.boundary_error { + assert!(!case.accepted); + assert!( + vitaminc_aead::Frame::parse(&frame).is_err(), + "{}: boundary refusal requires a malformed frame", + case.id + ); + assert_eq!(boundary.code, "stack_guest_abi::malformed_input"); + assert!(boundary.fields.is_empty()); + } + // No cipher/key source exists: declaration checking cannot do key I/O. + match (record::plan(&frame), case.expected_error) { + (Ok(plan), None) => { + if let Some(expected) = case.normalized { + let actual = normalized(&plan); + if actual != expected { + failures.push(format!( + "{}: normalized {actual}, expected {expected}", + case.id + )); + } + } + } + (Err(error), Some(expected)) => { + let code = error.code().unwrap().to_string(); + let fields = error.payload(); + if code != expected.code || fields != expected.fields { + failures.push(format!( + "{}: {code} {fields:?}, expected {} {:?}", + case.id, expected.code, expected.fields + )); + } + } + (actual, expected) => failures.push(format!( + "{}: accepted={}, expected accepted={}; error={:?}", + case.id, + actual.is_ok(), + expected.is_none(), + actual.err() + )), + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} diff --git a/scripts/__tests__/stack-conformance-ci.test.mjs b/scripts/__tests__/stack-conformance-ci.test.mjs new file mode 100644 index 000000000..b81e86d0a --- /dev/null +++ b/scripts/__tests__/stack-conformance-ci.test.mjs @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +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', () => { + const script = readFileSync( + join(REPO_ROOT, 'scripts/stack-conformance.sh'), + 'utf8', + ).replace(/^\s*#.*$/gm, '') + expect(script).toMatch(/cargo nextest run[^\n]*--test conformance/) + expect(script).toMatch(/cargo nextest run[^\n]*--test declarations/) + expect(script).toContain( + "go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance)$' ./encrypt", + ) + }) + + it('runs both consumers when the corpus changes', () => { + for (const file of ['tests-crates.yml', 'tests-golang.yml']) { + const workflow = readWorkflow(`.github/workflows/${file}`) + const triggers = workflow.on ?? workflow[true] + for (const event of ['pull_request', 'push']) { + expect(triggers[event].paths).toContain('conformance/stack-encrypt/**') + } + } + }) + + it('requires the Go guest and runs fresh exchange after building it', () => { + const workflow = readWorkflow('.github/workflows/tests-golang.yml') + expect(workflow.env.STACK_CONFORMANCE_REQUIRED).toBe('1') + const triggers = workflow.on ?? workflow[true] + for (const event of ['pull_request', 'push']) { + expect(triggers[event].paths).toContain('scripts/stack-conformance.sh') + } + const steps = workflow.jobs['wasi-check'].steps + const build = steps.findIndex( + (step) => step.run === 'mise run wasm:guest:build:deterministic', + ) + const exchange = steps.findIndex( + (step) => step.run === 'mise run test:conformance', + ) + expect(build).toBeGreaterThanOrEqual(0) + expect(exchange).toBeGreaterThan(build) + expect(steps[exchange].if).toBeUndefined() + expect(steps[exchange]['continue-on-error']).toBeUndefined() + }) +}) diff --git a/scripts/stack-conformance.sh b/scripts/stack-conformance.sh new file mode 100644 index 000000000..0b80b7d1d --- /dev/null +++ b/scripts/stack-conformance.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Credential-free shared cases plus fresh Go -> native Rust artifact exchange. +set -euo pipefail +cd "$(dirname "$0")/.." + +if [[ -n "${STACK_CONFORMANCE_UPDATE:-}" ]]; then + echo 'Conformance checks must not update frozen artifacts.' >&2 + exit 1 +fi +unset STACK_CONFORMANCE_INPUT STACK_CONFORMANCE_OUTPUT +export STACK_CONFORMANCE_REQUIRED=1 + +if [[ ! -s languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm ]]; then + echo 'Build the test guest first: mise run wasm:guest:build:deterministic' >&2 + exit 1 +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 +( + cd languages/golang + STACK_CONFORMANCE_OUTPUT="$artifacts/go.json" CGO_ENABLED=0 \ + go test -count=1 -v -run '^(TestStackConformance|TestDeclarationConformance)$' ./encrypt +) +test -s "$artifacts/go.json" +STACK_CONFORMANCE_INPUT="$artifacts/go.json" mise x --env test -- \ + cargo nextest run --locked -p stack-encrypt --all-features --test conformance