diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c15952b75..7c477a6cd 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -73,6 +73,10 @@ exclude_re = [ 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace for (DeclaredContext|ExpectedContext)>::from -> Self with Default::default\(\)$', + # The resolver of a build without EQL types holds none: its `targets` IS + # the empty list (`dynamic::target::NoTargets`), which a test asserts on + # and `resolve` turns into `TargetError::NoTargets`; `vec![]` is the body. + 'stack-encrypt/src/dynamic/target\.rs:\d+:\d+: replace ::targets -> Vec with vec!\[\]$', ] # Headroom over the measured baseline before a slow-but-correct mutant is diff --git a/.changeset/eql-plan-field-targets.md b/.changeset/eql-plan-field-targets.md new file mode 100644 index 000000000..e1fd16c14 --- /dev/null +++ b/.changeset/eql-plan-field-targets.md @@ -0,0 +1,5 @@ +--- +'@cipherstash/eql': minor +--- + +**The `eql-bindings` crate resolves an EQL type named as a string to its own Stack Encrypt plan** (`stack-encrypt` feature). `eql_bindings::encryption::targets` carries a catalog-generated table of every EQL type a data plan may name as a field target — its name across languages, family and suffix, the plaintext `ValueKind` it takes, the indexes it carries, its query twin, and whether the engine can produce it today with the reason when not — and `encrypt` / `decrypt` / `query` entry points that dispatch on the name and run the type's derived `EncryptFrom` / `DecryptInto`, resolving to the EQL value's JSON bytes through the engine's `Pending` so a guest batches it with the rest of a plan. This is the EQL half of EQL types as plan field targets (cipherstash/stack#1062): a Go data plan names `TextEq` and the guest returns the finished EQL value instead of assembling one. `TextEq` is the only producible type; every other name is refused with the plan's reason. The SQL surface and the TypeScript package are unchanged. diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 67531f441..9bfff334a 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -8,8 +8,9 @@ name: Tests (Go) # wasi-check the stack crates build for wasm32-wasip1 with no JS-host or # native-HTTP dependencies, the no-http shape passes its tests # and docs, both guests pass lint and tests and are built with -# their import surfaces checked (the stack-encrypt guest twice: -# the real build and the deterministic-kms test build), their +# their import surfaces checked (the stack-encrypt guest four +# times: the real build, the build with the EQL types, and the +# deterministic-kms test build of each), their # sha256 is recorded, `go:test` runs against them, and # `go generate` leaves the tree unchanged. # go-lint golangci-lint, Linux only. @@ -32,6 +33,9 @@ on: - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The eql guest build links eql-bindings. + - packages/eql/crates/eql-bindings/** + - packages/eql/crates/eql-domains/** - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh @@ -54,6 +58,9 @@ on: - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The eql guest build links eql-bindings. + - packages/eql/crates/eql-bindings/** + - packages/eql/crates/eql-domains/** - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh @@ -156,6 +163,18 @@ jobs: - name: stack-encrypt guest deterministic test build run: mise run wasm:guest:build:deterministic + # The build with the EQL types (ADR-0007, amended 2026-10-06), which + # package encrypt/eql embeds and registers on import, and its + # deterministic test build, which the hermetic tests of a TextEq field + # load. Same import-surface gate. The build task prints both builds' + # sizes: the plan asks for that measurement before the SDK settles on + # one build or two. + - name: stack-encrypt guest eql build and import-surface gate + run: mise run wasm:guest:build:eql + + - name: stack-encrypt guest eql deterministic test build + run: mise run wasm:guest:build:eql:deterministic + - name: Credential guest lint and tests run: mise run wasm:auth-guest:test @@ -167,7 +186,7 @@ jobs: # same bytes rather than a stale or rebuilt guest. - name: Record the guests' checksums run: | - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do (cd languages/golang && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") done @@ -180,6 +199,10 @@ jobs: languages/golang/encrypt/wasm/stack_encrypt_guest.wasm.sha256 languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm.sha256 + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm.sha256 + languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm + languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm.sha256 languages/golang/auth/wasm/stack_auth_guest.wasm languages/golang/auth/wasm/stack_auth_guest.wasm.sha256 if-no-files-found: error @@ -290,7 +313,7 @@ jobs: - name: The guests are the ones Linux built and checked run: | cd languages/golang - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then @@ -356,7 +379,7 @@ jobs: - name: The guests are the ones the wasi-check job built and checked run: | cd languages/golang - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then diff --git a/.gitignore b/.gitignore index b638c7cbb..320a815ee 100644 --- a/.gitignore +++ b/.gitignore @@ -109,8 +109,10 @@ mutants.out/ # The Go module's embedded WASI guests: build outputs of `mise run # wasm:guest:build` and `mise run wasm:auth-guest:build`. languages/golang/encrypt/wasm/*.wasm +languages/golang/encrypt/eql/wasm/*.wasm languages/golang/auth/wasm/*.wasm languages/golang/encrypt/wasm/*.sha256 +languages/golang/encrypt/eql/wasm/*.sha256 languages/golang/auth/wasm/*.sha256 # The deterministic-kms TEST build of the stack-encrypt guest, from `mise run # wasm:guest:build:deterministic`; under testdata so no binary embeds it. diff --git a/AGENTS.md b/AGENTS.md index da9c497b2..6e9020f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index b596cf753..dc2b95e1d 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -660,6 +660,9 @@ The ciphertext inside it is a Stack Encrypt ciphertext, which starts with `stack "EQL v4" in this plan is the name of that form, and not a new envelope. The engine produces one EQL type today: `TextEq`. +Status (2026-10-06, #1062): `TextEq` is producible through the data plan's target form. +`stashgen` accepts `encrypt_into=TextEq`, the guest build with the EQL types returns the finished value, and `encrypt/eql` holds the generated Go types; every other type is listed by `se_targets` with the reason it is not producible, and `stashgen` refuses it. +Open question for Dan: an EQL value is stored under a table and a column, so a target field's label must be exactly `/`; a cipher extended with a tenant part has no column for the extended label, and a plan with a target field refuses the extension rather than dropping it. The other types wait for work in the engine: - **Every family but `Text`:** how the family encodes a plaintext is not specified. diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index edd32c117..1d5ea21fb 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -21,8 +21,8 @@ Your program calls those functions, and it never builds or names a plan. type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt,index=equality;match"` - Name string `stash:"name,encrypt"` + Email string `stash:"email,encrypt_into=TextEq"` + Name string `stash:"name,encrypt_into=TextEq"` } ``` @@ -40,12 +40,11 @@ Your program calls those functions, and it never builds or names a plan. ```go encrypted, err := users.Encrypt(ctx, cipher, people) opened, err := users.Decrypt(ctx, cipher, encrypted) - term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com") + query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com") ``` 6. Store the encrypted type. - Each sealed field is one or more byte columns: `Email.Ciphertext`, `Email.Equality`, `Email.Match`. - `encrypt.Ciphertext` and each term type implement `driver.Valuer` and `sql.Scanner`, so a database library binds and scans each one as bytes; map each one to its own column. + Each `encrypt_into` field is one EQL column: `eql.TextEq` implements `driver.Valuer` and `sql.Scanner`, so a database library binds and scans it as the JSON a `public.eql_v3_text_eq` column holds. 7. Run the generator again after each change to the struct or to a tag. A change to the fields of the struct stops the build until you do. @@ -140,10 +139,14 @@ It ignores its own output file when it loads the package, so a stale file does n The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. `stashgen` checks each declaration with the engine, and holds no copy of the engine's rules: it runs the WASI guest the SDK embeds and asks it, one field at a time, so the error names the field. -It asks the engine for the EQL types it holds: each name, its plaintext type, its indexes and its query form. -This build of the engine produces no EQL type, so `encrypt_into` is refused with "EQL types are not available yet"; the next release adds `TextEq` and `encrypt/eql`. +It asks the engine for the EQL types it holds: each name, its plaintext type, its indexes, its query form, and whether the engine produces it today. +The command links the build of the engine that holds the EQL types (`encrypt/eql`), so it can answer for every type; a generated file imports `encrypt/eql` only when it names one. +The engine produces `TextEq` today; `encrypt_into` with any other type is refused with the type's name. Separate columns work today for four indexes: `equality`, `match`, `ore` and `ope`. +A field with `encrypt_into` is stored under its table and column, which is what an EQL value records in its `i`. +A cipher extended with a tenant part (`cipher.Extend(...)`) has no column for the extended label, so it refuses a struct with an `encrypt_into` field; use `index=` columns for a tenant-extended struct until that rule is settled. + ## When stashgen stops `stashgen` stops with an error, and writes no file, for each of these. @@ -216,5 +219,5 @@ A protobuf message with a `oneof` cannot be generated from a policy: protoc-gen- ## Status -The command runs the WASI guest the SDK embeds, so it needs the guest built: `mise run wasm:guest:build`. +The command runs the WASI guest the SDK embeds, so it needs the guests built: `mise run wasm:guest:build wasm:guest:build:eql`. The library, `github.com/cipherstash/stack/languages/golang/stashgen`, takes any `Engine`; `stashgen.Generate` takes one with `WithEngine`, and `stashgen/enginetest` has a static one for tests. diff --git a/languages/golang/cmd/stashgen/main.go b/languages/golang/cmd/stashgen/main.go index 1371a56de..7d1c30040 100644 --- a/languages/golang/cmd/stashgen/main.go +++ b/languages/golang/cmd/stashgen/main.go @@ -15,6 +15,11 @@ import ( "os" "strings" + // The build of the engine that holds the EQL types: the generator + // asks the embedded guest which EQL types it produces, so it links the + // build that has them. Generated code imports encrypt/eql only when it + // names one. + _ "github.com/cipherstash/stack/languages/golang/encrypt/eql" "github.com/cipherstash/stack/languages/golang/stashgen" ) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index 274ca0717..b832fcb4f 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" "github.com/cipherstash/stack/languages/golang/stashgen" "github.com/cipherstash/stack/languages/golang/stashgen/enginetest" ) @@ -118,9 +119,10 @@ func TestRunFlags(t *testing.T) { } } -// The real engine: the embedded guest, which produces no EQL type in this -// build, so a struct with encrypt_into is refused and nothing is written. -// Skips when the guest is not built. +// The real engine: the embedded guest, which the command links with the EQL +// types, so a struct with encrypt_into=TextEq generates, and a type the +// engine cannot produce yet is refused with nothing written. Skips when the +// guest is not built. func TestRunAsksTheEmbeddedEngine(t *testing.T) { checker, err := encrypt.NewChecker(context.Background()) if err != nil { @@ -129,16 +131,62 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { _ = checker.Close() dir := writeModule(t, map[string]string{"model.go": userSource}) var stdout, stderr bytes.Buffer + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 0 { + t.Fatalf("exit %d, want 0\n%s", code, stderr.String()) + } + written, err := os.ReadFile(filepath.Join(dir, "user_stash.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "eql.TextEq") || !strings.Contains(string(written), `EncryptInto("email", gensupport.String, "TextEq")`) { + t.Fatalf("the generated file does not name the EQL type:\n%s", written) + } + // A type the engine cannot produce yet: refused by name, nothing written. + unproducible := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt_into=TextMatch", 1) + dir = writeModule(t, map[string]string{"model.go": unproducible}) + stderr.Reset() if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) } - if !strings.Contains(stderr.String(), "EQL types are not available yet") { + // Refused by the generator with the engine's reason, before the engine + // is asked: se_targets lists the type as not producible. + if !strings.Contains(stderr.String(), "cannot produce the EQL type TextMatch yet") { t.Fatalf("stderr = %q", stderr.String()) } if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { t.Fatal("a file was written after the engine refused") } - // Separate columns are what the engine runs today. + // A Go type the EQL type does not seal: TextEq takes a string. The + // generator refuses it from the engine's plaintext kind, before the + // engine is asked. Only this test links encrypt/eql, so only it reaches + // the kind check against the real target list. + mismatched := strings.Replace(userSource, "Email string `stash:\"email,encrypt_into=TextEq\"`", "Age int64 `stash:\"age,encrypt_into=TextEq\"`", 1) + if mismatched == userSource { + t.Fatal("the fixture's Email line changed shape") + } + dir = writeModule(t, map[string]string{"model.go": mismatched}) + stderr.Reset() + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { + t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "User.Age") || !strings.Contains(stderr.String(), "TextEq seals a string, and int64 is int") { + t.Fatalf("stderr = %q", stderr.String()) + } + // A context of two segments leaves an EQL field no column: refused by + // name, before the engine is asked, and nothing written. + deep := strings.Replace(userSource, "context=users", "context=app/users", 1) + dir = writeModule(t, map[string]string{"model.go": deep}) + stderr.Reset() + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { + t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "User.Email") || !strings.Contains(stderr.String(), `"app/users" has 2 segments`) { + t.Fatalf("stderr = %q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { + t.Fatal("a file was written after the refusal") + } + // Separate columns, as before. columns := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt,index=equality;match", 1) dir = writeModule(t, map[string]string{"model.go": columns}) stderr.Reset() @@ -147,6 +195,33 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { } } +// stashgen.EQLGoName and eql-codegen's go_name hold one rule (Json is JSON). +// The generated encrypt/eql table records both names for every type, so a +// type whose GoName the Go rule does not reproduce is a rule the two no +// longer share. This test lives here because the command links encrypt/eql; +// the stashgen package's own tests must not, or the embedded engine they +// test changes build. +func TestEQLGoNameAgreesWithTheGeneratedPackage(t *testing.T) { + if len(eql.Types) == 0 { + t.Fatal("the generated eql.Types table is empty") + } + renamed := 0 + for _, typ := range eql.Types { + if got := stashgen.EQLGoName(typ.Name); got != typ.GoName { + t.Errorf("%s: EQLGoName = %s, generated GoName = %s", typ.Name, got, typ.GoName) + } + if typ.Name != typ.GoName { + renamed++ + } + } + if renamed == 0 { + t.Fatal("the rule renamed nothing: the Json family is spelled JSON") + } + if stashgen.EQLGoName("Json") != "JSON" || stashgen.EQLGoName("TextEq") != "TextEq" || stashgen.EQLGoName("SteVecQuery") != "SteVecQuery" { + t.Fatal("the one rule: a Json prefix is JSON, every other name is itself") + } +} + func TestModelFlagsRepeat(t *testing.T) { var m modelFlags for _, s := range []string{"Rows=ContactRow", "Legacy=userdb.Contact:contactRow"} { diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 62b504757..d5a88e9eb 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -215,6 +215,20 @@ reports it, `Client.MemoryLockError` says why not, and `WithRequireLockedMemory` makes `NewClient` refuse to start unlocked. See the package documentation for the full account. +## EQL columns + +A field tagged `encrypt_into=TextEq` is stored as one EQL value, the JSON +PostgreSQL holds in a `public.eql_v3_text_eq` column, as an `eql.TextEq` +from package `encrypt/eql`. The guest builds the value: the generated code +imports `encrypt/eql`, which embeds the build of the engine that holds the +EQL types and registers it on import, so the program runs that build and +stores what it returns. `Fields.Email.Query` returns the `eql.TextEqQuery` +the column's `eql_v3.query_text_eq` operand takes. `mise run +wasm:guest:build:eql` builds that guest beside the other. A cipher extended +with a tenant part refuses a struct with an `encrypt_into` field: an EQL +value is stored under a table and a column, and the extended label has no +column. + ## Building The package embeds `wasm/stack_encrypt_guest.wasm`, a build artefact of the diff --git a/languages/golang/encrypt/eql/eql.go b/languages/golang/encrypt/eql/eql.go new file mode 100644 index 000000000..24b3d7c17 --- /dev/null +++ b/languages/golang/encrypt/eql/eql.go @@ -0,0 +1,123 @@ +// Package eql holds the EQL types a program stores: one Go type per EQL +// type, each holding the EQL value as the JSON bytes PostgreSQL stores in +// the type's domain, and the query types that match them. +// +// A field with `stash:"email,encrypt_into=TextEq"` is an [TextEq] in the +// generated encrypted type, and its Fields entry's Query returns a +// [TextEqQuery]. The guest builds every value: generated code stores what +// the guest returns and never assembles an EQL value itself (ADR-0007, +// amended 2026-10-06). Each type implements driver.Valuer and sql.Scanner +// for one column, and json.Marshaler, which writes the value as the JSON +// it is. +// +// Importing this package links the build of the engine that holds the EQL +// types: eql_gen.go's init registers the embedded module with package +// encrypt, and that registration cannot fail. A generated file that names +// an EQL type imports this package, so a program with EQL types runs the +// build that has them and a program without runs the smaller one. +// +// The types and the [Types] table are generated from the EQL catalog by +// eql-codegen (`mise run types:generate` in packages/eql); eql_gen.go is +// committed and drift-gated. The engine produces [TextEq] today; every +// other type is listed with the reason it cannot be produced yet, and +// stashgen refuses it. +package eql + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// Type is one EQL type as the catalog describes it: what stashgen learns +// from the engine's se_targets export, in Go. Name is the engine's name, +// the value of encrypt_into; GoName is the type's name in this package. +type Type struct { + // Name is the type's name across languages, and the engine's: TextEq. + Name string + // GoName is the type's name in this package: Name, save the JSON family + // (Json is JSON). + GoName string + // Family is the catalog family: text. + Family string + // Suffix is the query-capability suffix: Eq; empty for a storage-only + // type. + Suffix string + // Plaintext is the wire kind the type is produced from (string, ...), + // or "" while unspecified. + Plaintext string + // SQLDomain is the stored value's PostgreSQL domain. + SQLDomain string + // Indexes are the indexes the type carries: eq, match, ore, ope, json. + Indexes []string + // Query is the query type's engine name, or "" for a storage-only type. + Query string + // QuerySQLDomain is the query type's PostgreSQL domain, or "". + QuerySQLDomain string + // Producible says whether the engine produces the type today. + Producible bool + // Reason says why not, when it does not. + Reason string +} + +// Lookup finds the type the engine names, or false. +func Lookup(name string) (Type, bool) { + for _, t := range Types { + if t.Name == name { + return t, true + } + } + return Type{}, false +} + +// errNull is a NULL scanned into an EQL type. +var errNull = errors.New("eql: cannot scan NULL into an EQL value") + +// value is an EQL value as the driver takes it: the JSON text, or NULL for +// an empty value. +func value(b []byte) (driver.Value, error) { + if b == nil { + return nil, nil + } + return string(b), nil +} + +// scan reads a column into an EQL value: jsonb comes back as bytes or text. +func scan(dst *[]byte, src any, kind string) error { + switch v := src.(type) { + case []byte: + out := make([]byte, len(v)) + copy(out, v) + *dst = out + return nil + case string: + *dst = []byte(v) + return nil + case nil: + return fmt.Errorf("%w: %s", errNull, kind) + default: + return fmt.Errorf("eql: cannot scan %T into %s", src, kind) + } +} + +// marshal is the EQL value as JSON: the bytes it is, null when empty. +func marshal(b []byte, kind string) ([]byte, error) { + if b == nil { + return []byte("null"), nil + } + if !json.Valid(b) { + return nil, fmt.Errorf("eql: %s holds bytes that are not JSON", kind) + } + return b, nil +} + +// unmarshal reads an EQL value from JSON: the document as it is. +func unmarshal(dst *[]byte, b []byte) error { + if string(b) == "null" { + *dst = nil + return nil + } + *dst = append([]byte(nil), b...) + return nil +} diff --git a/languages/golang/encrypt/eql/eql_gen.go b/languages/golang/encrypt/eql/eql_gen.go new file mode 100644 index 000000000..911a82c97 --- /dev/null +++ b/languages/golang/encrypt/eql/eql_gen.go @@ -0,0 +1,2207 @@ +// Code generated by eql-codegen from the eql-domains catalog. DO NOT EDIT. + +package eql + +import "database/sql/driver" + +// Types is every EQL type the catalog has, in catalog order: what the +// engine's se_targets export lists, as Go. Producible says whether the +// engine produces the type today; stashgen refuses encrypt_into for one +// it does not, with the Reason. +var Types = []Type{ + {Name: "Integer", GoName: "Integer", Family: "integer", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_integer", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerEq", GoName: "IntegerEq", Family: "integer", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_integer_eq", Indexes: []string{"eq"}, Query: "IntegerEqQuery", QuerySQLDomain: "eql_v3.query_integer_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrdOre", GoName: "IntegerOrdOre", Family: "integer", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord_ore", Indexes: []string{"ore"}, Query: "IntegerOrdOreQuery", QuerySQLDomain: "eql_v3.query_integer_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrd", GoName: "IntegerOrd", Family: "integer", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord", Indexes: []string{"ope"}, Query: "IntegerOrdQuery", QuerySQLDomain: "eql_v3.query_integer_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrdOpe", GoName: "IntegerOrdOpe", Family: "integer", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord_ope", Indexes: []string{"ope"}, Query: "IntegerOrdOpeQuery", QuerySQLDomain: "eql_v3.query_integer_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Smallint", GoName: "Smallint", Family: "smallint", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_smallint", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintEq", GoName: "SmallintEq", Family: "smallint", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_smallint_eq", Indexes: []string{"eq"}, Query: "SmallintEqQuery", QuerySQLDomain: "eql_v3.query_smallint_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrdOre", GoName: "SmallintOrdOre", Family: "smallint", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord_ore", Indexes: []string{"ore"}, Query: "SmallintOrdOreQuery", QuerySQLDomain: "eql_v3.query_smallint_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrd", GoName: "SmallintOrd", Family: "smallint", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord", Indexes: []string{"ope"}, Query: "SmallintOrdQuery", QuerySQLDomain: "eql_v3.query_smallint_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrdOpe", GoName: "SmallintOrdOpe", Family: "smallint", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord_ope", Indexes: []string{"ope"}, Query: "SmallintOrdOpeQuery", QuerySQLDomain: "eql_v3.query_smallint_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Bigint", GoName: "Bigint", Family: "bigint", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_bigint", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintEq", GoName: "BigintEq", Family: "bigint", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_bigint_eq", Indexes: []string{"eq"}, Query: "BigintEqQuery", QuerySQLDomain: "eql_v3.query_bigint_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrdOre", GoName: "BigintOrdOre", Family: "bigint", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord_ore", Indexes: []string{"ore"}, Query: "BigintOrdOreQuery", QuerySQLDomain: "eql_v3.query_bigint_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrd", GoName: "BigintOrd", Family: "bigint", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord", Indexes: []string{"ope"}, Query: "BigintOrdQuery", QuerySQLDomain: "eql_v3.query_bigint_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrdOpe", GoName: "BigintOrdOpe", Family: "bigint", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord_ope", Indexes: []string{"ope"}, Query: "BigintOrdOpeQuery", QuerySQLDomain: "eql_v3.query_bigint_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Date", GoName: "Date", Family: "date", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_date", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateEq", GoName: "DateEq", Family: "date", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_date_eq", Indexes: []string{"eq"}, Query: "DateEqQuery", QuerySQLDomain: "eql_v3.query_date_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrdOre", GoName: "DateOrdOre", Family: "date", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_date_ord_ore", Indexes: []string{"ore"}, Query: "DateOrdOreQuery", QuerySQLDomain: "eql_v3.query_date_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrd", GoName: "DateOrd", Family: "date", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_date_ord", Indexes: []string{"ope"}, Query: "DateOrdQuery", QuerySQLDomain: "eql_v3.query_date_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrdOpe", GoName: "DateOrdOpe", Family: "date", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_date_ord_ope", Indexes: []string{"ope"}, Query: "DateOrdOpeQuery", QuerySQLDomain: "eql_v3.query_date_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Timestamp", GoName: "Timestamp", Family: "timestamp", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_timestamp", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampEq", GoName: "TimestampEq", Family: "timestamp", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_eq", Indexes: []string{"eq"}, Query: "TimestampEqQuery", QuerySQLDomain: "eql_v3.query_timestamp_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrdOre", GoName: "TimestampOrdOre", Family: "timestamp", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord_ore", Indexes: []string{"ore"}, Query: "TimestampOrdOreQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrd", GoName: "TimestampOrd", Family: "timestamp", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord", Indexes: []string{"ope"}, Query: "TimestampOrdQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrdOpe", GoName: "TimestampOrdOpe", Family: "timestamp", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord_ope", Indexes: []string{"ope"}, Query: "TimestampOrdOpeQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Numeric", GoName: "Numeric", Family: "numeric", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_numeric", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericEq", GoName: "NumericEq", Family: "numeric", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_numeric_eq", Indexes: []string{"eq"}, Query: "NumericEqQuery", QuerySQLDomain: "eql_v3.query_numeric_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrdOre", GoName: "NumericOrdOre", Family: "numeric", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord_ore", Indexes: []string{"ore"}, Query: "NumericOrdOreQuery", QuerySQLDomain: "eql_v3.query_numeric_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrd", GoName: "NumericOrd", Family: "numeric", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord", Indexes: []string{"ope"}, Query: "NumericOrdQuery", QuerySQLDomain: "eql_v3.query_numeric_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrdOpe", GoName: "NumericOrdOpe", Family: "numeric", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord_ope", Indexes: []string{"ope"}, Query: "NumericOrdOpeQuery", QuerySQLDomain: "eql_v3.query_numeric_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Text", GoName: "Text", Family: "text", Suffix: "", Plaintext: "string", SQLDomain: "public.eql_v3_text", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL"}, + {Name: "TextEq", GoName: "TextEq", Family: "text", Suffix: "Eq", Plaintext: "string", SQLDomain: "public.eql_v3_text_eq", Indexes: []string{"eq"}, Query: "TextEqQuery", QuerySQLDomain: "eql_v3.query_text_eq", Producible: true, Reason: ""}, + {Name: "TextMatch", GoName: "TextMatch", Family: "text", Suffix: "Match", Plaintext: "string", SQLDomain: "public.eql_v3_text_match", Indexes: []string{"match"}, Query: "TextMatchQuery", QuerySQLDomain: "eql_v3.query_text_match", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextOrdOre", GoName: "TextOrdOre", Family: "text", Suffix: "OrdOre", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord_ore", Indexes: []string{"eq", "ore"}, Query: "TextOrdOreQuery", QuerySQLDomain: "eql_v3.query_text_ord_ore", Producible: false, Reason: "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms"}, + {Name: "TextOrd", GoName: "TextOrd", Family: "text", Suffix: "Ord", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord", Indexes: []string{"eq", "ope"}, Query: "TextOrdQuery", QuerySQLDomain: "eql_v3.query_text_ord", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextOrdOpe", GoName: "TextOrdOpe", Family: "text", Suffix: "OrdOpe", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord_ope", Indexes: []string{"eq", "ope"}, Query: "TextOrdOpeQuery", QuerySQLDomain: "eql_v3.query_text_ord_ope", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextSearchOre", GoName: "TextSearchOre", Family: "text", Suffix: "SearchOre", Plaintext: "string", SQLDomain: "public.eql_v3_text_search_ore", Indexes: []string{"eq", "ore", "match"}, Query: "TextSearchOreQuery", QuerySQLDomain: "eql_v3.query_text_search_ore", Producible: false, Reason: "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms"}, + {Name: "TextSearch", GoName: "TextSearch", Family: "text", Suffix: "Search", Plaintext: "string", SQLDomain: "public.eql_v3_text_search", Indexes: []string{"eq", "ope", "match"}, Query: "TextSearchQuery", QuerySQLDomain: "eql_v3.query_text_search", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "Boolean", GoName: "Boolean", Family: "boolean", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_boolean", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Real", GoName: "Real", Family: "real", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_real", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealEq", GoName: "RealEq", Family: "real", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_real_eq", Indexes: []string{"eq"}, Query: "RealEqQuery", QuerySQLDomain: "eql_v3.query_real_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrdOre", GoName: "RealOrdOre", Family: "real", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_real_ord_ore", Indexes: []string{"ore"}, Query: "RealOrdOreQuery", QuerySQLDomain: "eql_v3.query_real_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrd", GoName: "RealOrd", Family: "real", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_real_ord", Indexes: []string{"ope"}, Query: "RealOrdQuery", QuerySQLDomain: "eql_v3.query_real_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrdOpe", GoName: "RealOrdOpe", Family: "real", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_real_ord_ope", Indexes: []string{"ope"}, Query: "RealOrdOpeQuery", QuerySQLDomain: "eql_v3.query_real_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Double", GoName: "Double", Family: "double", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_double", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleEq", GoName: "DoubleEq", Family: "double", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_double_eq", Indexes: []string{"eq"}, Query: "DoubleEqQuery", QuerySQLDomain: "eql_v3.query_double_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrdOre", GoName: "DoubleOrdOre", Family: "double", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_double_ord_ore", Indexes: []string{"ore"}, Query: "DoubleOrdOreQuery", QuerySQLDomain: "eql_v3.query_double_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrd", GoName: "DoubleOrd", Family: "double", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_double_ord", Indexes: []string{"ope"}, Query: "DoubleOrdQuery", QuerySQLDomain: "eql_v3.query_double_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrdOpe", GoName: "DoubleOrdOpe", Family: "double", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_double_ord_ope", Indexes: []string{"ope"}, Query: "DoubleOrdOpeQuery", QuerySQLDomain: "eql_v3.query_double_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SteVecDocument", GoName: "SteVecDocument", Family: "json", Suffix: "Search", Plaintext: "", SQLDomain: "public.eql_v3_json_search", Indexes: []string{"json"}, Query: "SteVecQuery", QuerySQLDomain: "eql_v3.query_json", Producible: false, Reason: "the JSON index is a new operation in the engine"}, + {Name: "Json", GoName: "JSON", Family: "json", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_json", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, +} + +// Integer is the EQL type stored in public.eql_v3_integer: the integer family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Integer []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Integer) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer column. +func (v *Integer) Scan(src any) error { + return scan((*[]byte)(v), src, "Integer") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Integer) MarshalJSON() ([]byte, error) { + return marshal(v, "Integer") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Integer) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerEq is the EQL type stored in public.eql_v3_integer_eq: the integer family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_eq column. +func (v *IntegerEq) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerEq) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerEqQuery is the query value for IntegerEq: the operand eql_v3.query_integer_eq takes. +type IntegerEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_eq column. +func (v *IntegerEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOre is the EQL type stored in public.eql_v3_integer_ord_ore: the integer family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord_ore column. +func (v *IntegerOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOreQuery is the query value for IntegerOrdOre: the operand eql_v3.query_integer_ord_ore takes. +type IntegerOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord_ore column. +func (v *IntegerOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrd is the EQL type stored in public.eql_v3_integer_ord: the integer family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord column. +func (v *IntegerOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdQuery is the query value for IntegerOrd: the operand eql_v3.query_integer_ord takes. +type IntegerOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord column. +func (v *IntegerOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOpe is the EQL type stored in public.eql_v3_integer_ord_ope: the integer family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord_ope column. +func (v *IntegerOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOpeQuery is the query value for IntegerOrdOpe: the operand eql_v3.query_integer_ord_ope takes. +type IntegerOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord_ope column. +func (v *IntegerOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Smallint is the EQL type stored in public.eql_v3_smallint: the smallint family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Smallint []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Smallint) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint column. +func (v *Smallint) Scan(src any) error { + return scan((*[]byte)(v), src, "Smallint") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Smallint) MarshalJSON() ([]byte, error) { + return marshal(v, "Smallint") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Smallint) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintEq is the EQL type stored in public.eql_v3_smallint_eq: the smallint family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_eq column. +func (v *SmallintEq) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintEq) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintEqQuery is the query value for SmallintEq: the operand eql_v3.query_smallint_eq takes. +type SmallintEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_eq column. +func (v *SmallintEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOre is the EQL type stored in public.eql_v3_smallint_ord_ore: the smallint family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord_ore column. +func (v *SmallintOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOreQuery is the query value for SmallintOrdOre: the operand eql_v3.query_smallint_ord_ore takes. +type SmallintOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord_ore column. +func (v *SmallintOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrd is the EQL type stored in public.eql_v3_smallint_ord: the smallint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord column. +func (v *SmallintOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdQuery is the query value for SmallintOrd: the operand eql_v3.query_smallint_ord takes. +type SmallintOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord column. +func (v *SmallintOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOpe is the EQL type stored in public.eql_v3_smallint_ord_ope: the smallint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord_ope column. +func (v *SmallintOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOpeQuery is the query value for SmallintOrdOpe: the operand eql_v3.query_smallint_ord_ope takes. +type SmallintOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord_ope column. +func (v *SmallintOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Bigint is the EQL type stored in public.eql_v3_bigint: the bigint family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Bigint []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Bigint) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint column. +func (v *Bigint) Scan(src any) error { + return scan((*[]byte)(v), src, "Bigint") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Bigint) MarshalJSON() ([]byte, error) { + return marshal(v, "Bigint") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Bigint) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintEq is the EQL type stored in public.eql_v3_bigint_eq: the bigint family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_eq column. +func (v *BigintEq) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintEq) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintEqQuery is the query value for BigintEq: the operand eql_v3.query_bigint_eq takes. +type BigintEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_eq column. +func (v *BigintEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOre is the EQL type stored in public.eql_v3_bigint_ord_ore: the bigint family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord_ore column. +func (v *BigintOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOreQuery is the query value for BigintOrdOre: the operand eql_v3.query_bigint_ord_ore takes. +type BigintOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord_ore column. +func (v *BigintOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrd is the EQL type stored in public.eql_v3_bigint_ord: the bigint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord column. +func (v *BigintOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdQuery is the query value for BigintOrd: the operand eql_v3.query_bigint_ord takes. +type BigintOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord column. +func (v *BigintOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOpe is the EQL type stored in public.eql_v3_bigint_ord_ope: the bigint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord_ope column. +func (v *BigintOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOpeQuery is the query value for BigintOrdOpe: the operand eql_v3.query_bigint_ord_ope takes. +type BigintOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord_ope column. +func (v *BigintOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Date is the EQL type stored in public.eql_v3_date: the date family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Date []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Date) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date column. +func (v *Date) Scan(src any) error { + return scan((*[]byte)(v), src, "Date") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Date) MarshalJSON() ([]byte, error) { + return marshal(v, "Date") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Date) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateEq is the EQL type stored in public.eql_v3_date_eq: the date family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_eq column. +func (v *DateEq) Scan(src any) error { + return scan((*[]byte)(v), src, "DateEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateEq) MarshalJSON() ([]byte, error) { + return marshal(v, "DateEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateEqQuery is the query value for DateEq: the operand eql_v3.query_date_eq takes. +type DateEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_eq column. +func (v *DateEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOre is the EQL type stored in public.eql_v3_date_ord_ore: the date family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord_ore column. +func (v *DateOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOreQuery is the query value for DateOrdOre: the operand eql_v3.query_date_ord_ore takes. +type DateOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord_ore column. +func (v *DateOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrd is the EQL type stored in public.eql_v3_date_ord: the date family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord column. +func (v *DateOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdQuery is the query value for DateOrd: the operand eql_v3.query_date_ord takes. +type DateOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord column. +func (v *DateOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOpe is the EQL type stored in public.eql_v3_date_ord_ope: the date family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord_ope column. +func (v *DateOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOpeQuery is the query value for DateOrdOpe: the operand eql_v3.query_date_ord_ope takes. +type DateOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord_ope column. +func (v *DateOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Timestamp is the EQL type stored in public.eql_v3_timestamp: the timestamp family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Timestamp []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Timestamp) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp column. +func (v *Timestamp) Scan(src any) error { + return scan((*[]byte)(v), src, "Timestamp") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Timestamp) MarshalJSON() ([]byte, error) { + return marshal(v, "Timestamp") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Timestamp) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampEq is the EQL type stored in public.eql_v3_timestamp_eq: the timestamp family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_eq column. +func (v *TimestampEq) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampEq) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampEqQuery is the query value for TimestampEq: the operand eql_v3.query_timestamp_eq takes. +type TimestampEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_eq column. +func (v *TimestampEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOre is the EQL type stored in public.eql_v3_timestamp_ord_ore: the timestamp family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord_ore column. +func (v *TimestampOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOreQuery is the query value for TimestampOrdOre: the operand eql_v3.query_timestamp_ord_ore takes. +type TimestampOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord_ore column. +func (v *TimestampOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrd is the EQL type stored in public.eql_v3_timestamp_ord: the timestamp family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord column. +func (v *TimestampOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdQuery is the query value for TimestampOrd: the operand eql_v3.query_timestamp_ord takes. +type TimestampOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord column. +func (v *TimestampOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOpe is the EQL type stored in public.eql_v3_timestamp_ord_ope: the timestamp family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord_ope column. +func (v *TimestampOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOpeQuery is the query value for TimestampOrdOpe: the operand eql_v3.query_timestamp_ord_ope takes. +type TimestampOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord_ope column. +func (v *TimestampOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Numeric is the EQL type stored in public.eql_v3_numeric: the numeric family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Numeric []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Numeric) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric column. +func (v *Numeric) Scan(src any) error { + return scan((*[]byte)(v), src, "Numeric") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Numeric) MarshalJSON() ([]byte, error) { + return marshal(v, "Numeric") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Numeric) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericEq is the EQL type stored in public.eql_v3_numeric_eq: the numeric family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_eq column. +func (v *NumericEq) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericEq) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericEqQuery is the query value for NumericEq: the operand eql_v3.query_numeric_eq takes. +type NumericEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_eq column. +func (v *NumericEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOre is the EQL type stored in public.eql_v3_numeric_ord_ore: the numeric family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord_ore column. +func (v *NumericOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOreQuery is the query value for NumericOrdOre: the operand eql_v3.query_numeric_ord_ore takes. +type NumericOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord_ore column. +func (v *NumericOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrd is the EQL type stored in public.eql_v3_numeric_ord: the numeric family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord column. +func (v *NumericOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdQuery is the query value for NumericOrd: the operand eql_v3.query_numeric_ord takes. +type NumericOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord column. +func (v *NumericOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOpe is the EQL type stored in public.eql_v3_numeric_ord_ope: the numeric family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord_ope column. +func (v *NumericOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOpeQuery is the query value for NumericOrdOpe: the operand eql_v3.query_numeric_ord_ope takes. +type NumericOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord_ope column. +func (v *NumericOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Text is the EQL type stored in public.eql_v3_text: the text family with no index. +// The engine cannot produce it yet: the storage-only text domain follows once TextEq is proven end to end in PostgreSQL. +type Text []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Text) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text column. +func (v *Text) Scan(src any) error { + return scan((*[]byte)(v), src, "Text") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Text) MarshalJSON() ([]byte, error) { + return marshal(v, "Text") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Text) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextEq is the EQL type stored in public.eql_v3_text_eq: the text family with the eq index. +// The engine produces it from a string. +type TextEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_eq column. +func (v *TextEq) Scan(src any) error { + return scan((*[]byte)(v), src, "TextEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextEq) MarshalJSON() ([]byte, error) { + return marshal(v, "TextEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextEqQuery is the query value for TextEq: the operand eql_v3.query_text_eq takes. +type TextEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_eq column. +func (v *TextEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextMatch is the EQL type stored in public.eql_v3_text_match: the text family with the match index. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextMatch []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextMatch) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_match column. +func (v *TextMatch) Scan(src any) error { + return scan((*[]byte)(v), src, "TextMatch") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextMatch) MarshalJSON() ([]byte, error) { + return marshal(v, "TextMatch") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextMatch) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextMatchQuery is the query value for TextMatch: the operand eql_v3.query_text_match takes. +type TextMatchQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextMatchQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_match column. +func (v *TextMatchQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextMatchQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextMatchQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextMatchQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextMatchQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOre is the EQL type stored in public.eql_v3_text_ord_ore: the text family with the eq and ore indexes. +// The engine cannot produce it yet: EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms. +type TextOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord_ore column. +func (v *TextOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOreQuery is the query value for TextOrdOre: the operand eql_v3.query_text_ord_ore takes. +type TextOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord_ore column. +func (v *TextOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrd is the EQL type stored in public.eql_v3_text_ord: the text family with the eq and ope indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord column. +func (v *TextOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdQuery is the query value for TextOrd: the operand eql_v3.query_text_ord takes. +type TextOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord column. +func (v *TextOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOpe is the EQL type stored in public.eql_v3_text_ord_ope: the text family with the eq and ope indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord_ope column. +func (v *TextOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOpeQuery is the query value for TextOrdOpe: the operand eql_v3.query_text_ord_ope takes. +type TextOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord_ope column. +func (v *TextOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchOre is the EQL type stored in public.eql_v3_text_search_ore: the text family with the eq and ore and match indexes. +// The engine cannot produce it yet: EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms. +type TextSearchOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_search_ore column. +func (v *TextSearchOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchOreQuery is the query value for TextSearchOre: the operand eql_v3.query_text_search_ore takes. +type TextSearchOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_search_ore column. +func (v *TextSearchOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearch is the EQL type stored in public.eql_v3_text_search: the text family with the eq and ope and match indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextSearch []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearch) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_search column. +func (v *TextSearch) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearch") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearch) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearch") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearch) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchQuery is the query value for TextSearch: the operand eql_v3.query_text_search takes. +type TextSearchQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_search column. +func (v *TextSearchQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Boolean is the EQL type stored in public.eql_v3_boolean: the boolean family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Boolean []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Boolean) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_boolean column. +func (v *Boolean) Scan(src any) error { + return scan((*[]byte)(v), src, "Boolean") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Boolean) MarshalJSON() ([]byte, error) { + return marshal(v, "Boolean") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Boolean) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Real is the EQL type stored in public.eql_v3_real: the real family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Real []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Real) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real column. +func (v *Real) Scan(src any) error { + return scan((*[]byte)(v), src, "Real") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Real) MarshalJSON() ([]byte, error) { + return marshal(v, "Real") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Real) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealEq is the EQL type stored in public.eql_v3_real_eq: the real family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_eq column. +func (v *RealEq) Scan(src any) error { + return scan((*[]byte)(v), src, "RealEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealEq) MarshalJSON() ([]byte, error) { + return marshal(v, "RealEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealEqQuery is the query value for RealEq: the operand eql_v3.query_real_eq takes. +type RealEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_eq column. +func (v *RealEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOre is the EQL type stored in public.eql_v3_real_ord_ore: the real family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord_ore column. +func (v *RealOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOreQuery is the query value for RealOrdOre: the operand eql_v3.query_real_ord_ore takes. +type RealOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord_ore column. +func (v *RealOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrd is the EQL type stored in public.eql_v3_real_ord: the real family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord column. +func (v *RealOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdQuery is the query value for RealOrd: the operand eql_v3.query_real_ord takes. +type RealOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord column. +func (v *RealOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOpe is the EQL type stored in public.eql_v3_real_ord_ope: the real family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord_ope column. +func (v *RealOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOpeQuery is the query value for RealOrdOpe: the operand eql_v3.query_real_ord_ope takes. +type RealOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord_ope column. +func (v *RealOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Double is the EQL type stored in public.eql_v3_double: the double family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Double []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Double) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double column. +func (v *Double) Scan(src any) error { + return scan((*[]byte)(v), src, "Double") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Double) MarshalJSON() ([]byte, error) { + return marshal(v, "Double") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Double) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleEq is the EQL type stored in public.eql_v3_double_eq: the double family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_eq column. +func (v *DoubleEq) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleEq) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleEqQuery is the query value for DoubleEq: the operand eql_v3.query_double_eq takes. +type DoubleEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_eq column. +func (v *DoubleEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOre is the EQL type stored in public.eql_v3_double_ord_ore: the double family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord_ore column. +func (v *DoubleOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOreQuery is the query value for DoubleOrdOre: the operand eql_v3.query_double_ord_ore takes. +type DoubleOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord_ore column. +func (v *DoubleOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrd is the EQL type stored in public.eql_v3_double_ord: the double family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord column. +func (v *DoubleOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdQuery is the query value for DoubleOrd: the operand eql_v3.query_double_ord takes. +type DoubleOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord column. +func (v *DoubleOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOpe is the EQL type stored in public.eql_v3_double_ord_ope: the double family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord_ope column. +func (v *DoubleOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOpeQuery is the query value for DoubleOrdOpe: the operand eql_v3.query_double_ord_ope takes. +type DoubleOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord_ope column. +func (v *DoubleOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SteVecDocument is the EQL type stored in public.eql_v3_json_search: the json family with the json index. +// The engine cannot produce it yet: the JSON index is a new operation in the engine. +type SteVecDocument []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SteVecDocument) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_json_search column. +func (v *SteVecDocument) Scan(src any) error { + return scan((*[]byte)(v), src, "SteVecDocument") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SteVecDocument) MarshalJSON() ([]byte, error) { + return marshal(v, "SteVecDocument") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SteVecDocument) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SteVecQuery is the query value for SteVecDocument: the operand eql_v3.query_json takes. +type SteVecQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SteVecQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_json column. +func (v *SteVecQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SteVecQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SteVecQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SteVecQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SteVecQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// JSON is the EQL type stored in public.eql_v3_json: the json family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type JSON []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v JSON) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_json column. +func (v *JSON) Scan(src any) error { + return scan((*[]byte)(v), src, "JSON") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v JSON) MarshalJSON() ([]byte, error) { + return marshal(v, "JSON") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *JSON) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} diff --git a/languages/golang/encrypt/eql/eql_test.go b/languages/golang/encrypt/eql/eql_test.go new file mode 100644 index 000000000..d951dc36f --- /dev/null +++ b/languages/golang/encrypt/eql/eql_test.go @@ -0,0 +1,79 @@ +package eql_test + +import ( + "encoding/json" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt/eql" +) + +func TestTypesTableNamesTextEqAsTheOneProducibleType(t *testing.T) { + var producible []string + for _, typ := range eql.Types { + if typ.Producible { + producible = append(producible, typ.Name) + if typ.Reason != "" { + t.Errorf("%s: a producible type has no reason", typ.Name) + } + } else if typ.Reason == "" { + t.Errorf("%s: an unproducible type has a reason", typ.Name) + } + } + if len(producible) != 1 || producible[0] != "TextEq" { + t.Fatalf("producible = %v, want [TextEq]", producible) + } + textEq, ok := eql.Lookup("TextEq") + if !ok || textEq.GoName != "TextEq" || textEq.Plaintext != "string" || textEq.Query != "TextEqQuery" || textEq.SQLDomain != "public.eql_v3_text_eq" || len(textEq.Indexes) != 1 || textEq.Indexes[0] != "eq" { + t.Fatalf("TextEq = %+v", textEq) + } + if j, ok := eql.Lookup("Json"); !ok || j.GoName != "JSON" { + t.Fatalf("Json's Go name is JSON: %+v", j) + } + if _, ok := eql.Lookup("Nope"); ok { + t.Fatal("Lookup found a type that does not exist") + } +} + +func TestValuesRoundTripThroughTheDriverAndJSON(t *testing.T) { + doc := []byte(`{"v":3,"i":{"t":"users","c":"email"},"c":"stack-encrypt:1:AA==","hm":"00"}`) + v := eql.TextEq(doc) + value, err := v.Value() + if err != nil || value != string(doc) { + t.Fatalf("Value = %v, %v", value, err) + } + for _, src := range []any{doc, string(doc)} { + var scanned eql.TextEq + if err := scanned.Scan(src); err != nil || string(scanned) != string(doc) { + t.Fatalf("Scan(%T) = %s, %v", src, scanned, err) + } + } + var scanned eql.TextEq + if err := scanned.Scan(nil); err == nil { + t.Fatal("NULL scanned into an EQL value") + } + if err := scanned.Scan(42); err == nil { + t.Fatal("an integer scanned into an EQL value") + } + out, err := json.Marshal(struct{ Email eql.TextEq }{v}) + if err != nil || string(out) != `{"Email":`+string(doc)+`}` { + t.Fatalf("Marshal = %s, %v", out, err) + } + var back struct{ Email eql.TextEq } + if err := json.Unmarshal(out, &back); err != nil || string(back.Email) != string(doc) { + t.Fatalf("Unmarshal = %s, %v", back.Email, err) + } + empty, err := json.Marshal(struct{ Email eql.TextEq }{}) + if err != nil || string(empty) != `{"Email":null}` { + t.Fatalf("an empty value marshals as null: %s, %v", empty, err) + } + if nilValue, err := eql.TextEq(nil).Value(); err != nil || nilValue != nil { + t.Fatalf("an empty value is NULL: %v, %v", nilValue, err) + } + if _, err := eql.TextEq([]byte("not json")).MarshalJSON(); err == nil { + t.Fatal("bytes that are not JSON marshalled") + } + var q eql.TextEqQuery + if err := json.Unmarshal([]byte("null"), &q); err != nil || q != nil { + t.Fatalf("null unmarshals to an empty value: %v, %v", q, err) + } +} diff --git a/languages/golang/encrypt/eql/guest.go b/languages/golang/encrypt/eql/guest.go new file mode 100644 index 000000000..d38aee3ec --- /dev/null +++ b/languages/golang/encrypt/eql/guest.go @@ -0,0 +1,32 @@ +package eql + +import ( + "embed" + + "github.com/cipherstash/stack/languages/golang/encrypt/internal/eqlguest" +) + +// The guest module with the EQL types is a build artefact of the Rust +// crate in ../guest with its `eql` feature, copied here by +// `mise run wasm:guest:build:eql`. It is embedded as a directory so the +// package compiles without it; encrypt.NewClient reports its absence. Its +// deterministic-kms test build lives under encrypt/testdata with the other +// test build: nothing test-only is embedded. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_encrypt_guest_eql.wasm" + +// Linking this package selects the build of the engine that holds the EQL +// types, and only that build: a program whose generated code names an EQL +// type must not fall back to the build without them, where every +// encrypt_into call would fail. The registration cannot fail; an absent +// module registers nil, and encrypt.NewClient reports ErrEQLGuestNotBuilt. +func init() { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + wasm = nil + } + eqlguest.Register(wasm) +} diff --git a/languages/golang/encrypt/eql/wasm/README.md b/languages/golang/encrypt/eql/wasm/README.md new file mode 100644 index 000000000..10baaac6f --- /dev/null +++ b/languages/golang/encrypt/eql/wasm/README.md @@ -0,0 +1,14 @@ +# Guest modules with the EQL types + +`stack_encrypt_guest_eql.wasm` is the stack-encrypt guest (`../../guest`) +built with its `eql` feature by `mise run wasm:guest:build:eql`: the build +that links `eql-bindings` and so can run a plan field that names an EQL type +(`TextEq`). It is not committed. Package `eql` embeds this directory and +registers the module on import, so a program whose generated code names an +EQL type runs this build; `NewClient` reports `ErrGuestNotBuilt` when it is +absent, and the package's tests skip. + +The same build with the `deterministic-kms` feature too — a TEST build whose +keys derive from a seed, so the tests seal and open a `TextEq` field with no +ZeroKMS — is written by `mise run wasm:guest:build:eql:deterministic` under +`encrypt/testdata/`, not here: nothing test-only is embedded. diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go new file mode 100644 index 000000000..833724c85 --- /dev/null +++ b/languages/golang/encrypt/eql_test.go @@ -0,0 +1,343 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// A field sealed into an EQL type, through generated code, over the +// deterministic test build of the guest WITH the EQL types (ADR-0007, +// amended 2026-10-06): the guest runs TextEq's own plan and returns the +// finished value, Go stores it as it is. The other build refuses the same +// declaration before any value crosses. + +func deterministicEQLClient(t *testing.T) *encrypt.Client { + t.Helper() + c, err := encrypt.NewDeterministicEQLClient(context.Background(), testSeed) + if errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} + +var contacts = []testusers.Contact{ + {ID: 1, Email: "bob@example.com", Notes: "likes cats"}, + {ID: 2, Email: "alice@example.com", Notes: "likes dogs"}, + {ID: 3, Email: "bob@example.com"}, +} + +// eqlEnvelope is the EQL v3 envelope of a TextEq value, as PostgreSQL's +// public.eql_v3_text_eq domain checks it. +type eqlEnvelope struct { + V int `json:"v"` + I struct{ T, C string } + C string `json:"c"` + HM string `json:"hm"` +} + +func decodeEnvelope(t *testing.T, value []byte) eqlEnvelope { + t.Helper() + var raw map[string]json.RawMessage + if err := json.Unmarshal(value, &raw); err != nil { + t.Fatalf("the EQL value is not JSON: %v: %s", err, value) + } + var e eqlEnvelope + if err := json.Unmarshal(value, &e); err != nil { + t.Fatal(err) + } + var id struct { + T string `json:"t"` + C string `json:"c"` + } + if err := json.Unmarshal(raw["i"], &id); err != nil { + t.Fatal(err) + } + e.I.T, e.I.C = id.T, id.C + if len(raw) != 4 { + t.Fatalf("a TextEq value has exactly v, i, c and hm: %s", value) + } + return e +} + +func TestTextEqFieldSealsToTheEQLEnvelope(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + encrypted, err := testusers.EncryptContact(ctx, cipher, contacts) + if err != nil { + t.Fatal(err) + } + if len(encrypted) != len(contacts) { + t.Fatalf("%d rows for %d values", len(encrypted), len(contacts)) + } + for i, e := range encrypted { + if e.ID != contacts[i].ID { + t.Errorf("row %d: passthrough id %d, want %d", i, e.ID, contacts[i].ID) + } + env := decodeEnvelope(t, e.Email) + if env.V != 3 { + t.Errorf("row %d: v = %d, want 3", i, env.V) + } + if env.I.T != "users" || env.I.C != "email" { + t.Errorf("row %d: i = %+v, want users/email", i, env.I) + } + if !strings.HasPrefix(env.C, "stack-encrypt:1:") { + t.Errorf("row %d: c does not carry the stack-encrypt producer marker: %q", i, env.C) + } + if len(env.HM) != 64 { + t.Errorf("row %d: hm is %d hex characters, want 64", i, len(env.HM)) + } + if len(e.Notes.Ciphertext) == 0 { + t.Errorf("row %d: notes not sealed", i) + } + } + // Equal plaintexts share the equality term and nothing else. + first, third := decodeEnvelope(t, encrypted[0].Email), decodeEnvelope(t, encrypted[2].Email) + if first.HM != third.HM { + t.Error("equal emails must share hm") + } + if first.C == third.C { + t.Error("each write seals under a fresh key") + } + if first.HM == decodeEnvelope(t, encrypted[1].Email).HM { + t.Error("different emails must not share hm") + } + + // The value is what the driver stores and reads back. + v, err := encrypted[0].Email.Value() + if err != nil || v.(string) != string(encrypted[0].Email) { + t.Fatalf("Value = %v, %v", v, err) + } + var scanned eql.TextEq + if err := scanned.Scan([]byte(encrypted[0].Email)); err != nil || !bytes.Equal(scanned, encrypted[0].Email) { + t.Fatalf("Scan = %s, %v", scanned, err) + } + + // Opens through the cipher and through the client. + for _, d := range []encrypt.Decrypter{cipher, c} { + decrypted, err := testusers.DecryptContact(ctx, d, encrypted) + if err != nil { + t.Fatal(err) + } + for i := range contacts { + if decrypted[i] != contacts[i] { + t.Errorf("row %d: %+v, want %+v", i, decrypted[i], contacts[i]) + } + } + } +} + +func TestTextEqQueryMatchesTheStoredValueAndTheRustFixture(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "packages", "eql", "tests", "encryption", "fixtures", "text_eq_query.json")) + if err != nil { + t.Fatal(err) + } + var fixture struct { + Table, Column, Plaintext, Query string + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + if fixture.Table != "users" || fixture.Column != "email" { + t.Fatalf("the fixture's column changed: %+v", fixture) + } + + query, err := testusers.ContactFields.Email.Query(ctx, cipher, fixture.Plaintext) + if err != nil { + t.Fatal(err) + } + // Byte for byte what eql-bindings' own dispatch derived under the same + // index key: the Go field runs TextEq's plan and no other. + if string(query) != fixture.Query { + t.Fatalf("Query = %s\nwant %s", query, fixture.Query) + } + stored, err := testusers.ContactFields.Email.Encrypt(ctx, cipher, fixture.Plaintext) + if err != nil { + t.Fatal(err) + } + var probe struct { + HM string `json:"hm"` + } + if err := json.Unmarshal(query, &probe); err != nil { + t.Fatal(err) + } + if env := decodeEnvelope(t, stored); env.HM != probe.HM { + t.Fatalf("the query's hm %s does not match the stored %s", probe.HM, env.HM) + } + if other, _ := testusers.ContactFields.Email.Query(ctx, cipher, "Bob@example.com"); string(other) == fixture.Query { + t.Fatal("no case folding: a different plaintext has a different term") + } +} + +// A stored EQL value changed before it is opened: moved to another column, +// or replaced with bytes that are not a TextEq. Neither opens, and the +// second is refused as malformed input before any key is retrieved. +func TestATamperedEQLValueDoesNotOpen(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + moved, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + original := moved[0].Email + moved[0].Email = eql.TextEq(bytes.Replace(original, []byte(`"c":"email"`), []byte(`"c":"notes"`), 1)) + if bytes.Equal(moved[0].Email, original) { + t.Fatal("the stored identifier was not where the test expected it") + } + if _, err := testusers.DecryptContact(ctx, cipher, moved); err == nil { + t.Fatal("an EQL value moved to another column opened") + } + + junk, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + for name, value := range map[string]eql.TextEq{ + "an empty object": eql.TextEq(`{}`), + "not JSON": eql.TextEq(`not json`), + "a query value": eql.TextEq(`{"v":3,"i":{"t":"users","c":"email"},"hm":"00"}`), + } { + junk[0].Email = value + if _, err := testusers.DecryptContact(ctx, cipher, junk); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("%s: Decrypt = %v, want ErrEncoding", name, err) + } + } + + // One flipped byte in the ciphertext inside the value: authenticated, + // so it does not open; the untouched value still does. + flipped, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + var doc map[string]json.RawMessage + if err := json.Unmarshal(flipped[0].Email, &doc); err != nil { + t.Fatal(err) + } + var ct string + if err := json.Unmarshal(doc["c"], &ct); err != nil { + t.Fatal(err) + } + last := []byte(ct) + if last[len(last)-2] == 'A' { + last[len(last)-2] = 'B' + } else { + last[len(last)-2] = 'A' + } + doc["c"], _ = json.Marshal(string(last)) + tampered, _ := json.Marshal(doc) + flipped[0].Email = eql.TextEq(tampered) + if _, err := testusers.DecryptContact(ctx, cipher, flipped); err == nil { + t.Fatal("a tampered ciphertext opened") + } + if _, err := testusers.DecryptContact(ctx, cipher, contactsSealed(t, ctx, cipher)); err != nil { + t.Fatalf("the untouched value no longer opens: %v", err) + } +} + +func contactsSealed(t *testing.T, ctx context.Context, cipher *encrypt.Cipher) []testusers.EncryptedContact { + t.Helper() + sealed, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + return sealed +} + +func TestTextEqIsRefusedByTheBuildWithoutEQLTypes(t *testing.T) { + ctx := context.Background() + plain, err := encrypt.PlainGuest() + if errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + checker, err := encrypt.NewCheckerOver(ctx, plain) + if err != nil { + t.Fatal(err) + } + defer func() { _ = checker.Close() }() + targets, err := checker.Targets(ctx) + if err != nil { + t.Fatal(err) + } + if len(targets) != 0 { + t.Fatalf("the build without EQL types lists %d targets", len(targets)) + } + plan := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := checker.Check(ctx, plan); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("Check = %v, want ErrEncoding", err) + } + + // The build with them lists the catalog and runs the plan. + withEQL, err := encrypt.EmbeddedGuest() + if err != nil { + t.Fatal(err) + } + eqlChecker, err := encrypt.NewCheckerOver(ctx, withEQL) + if err != nil { + t.Fatal(err) + } + defer func() { _ = eqlChecker.Close() }() + targets, err = eqlChecker.Targets(ctx) + if err != nil { + t.Fatal(err) + } + found := false + for _, target := range targets { + found = found || target.Name == "TextEq" + } + if !found { + t.Fatalf("TextEq is not among the %d targets", len(targets)) + } + if err := eqlChecker.Check(ctx, plan); err != nil { + t.Fatalf("the eql build refuses TextEq: %v", err) + } + for _, name := range []string{"TextOrdOre", "Nope"} { + refused := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: name}}} + if err := eqlChecker.Check(ctx, refused); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("%s: Check = %v, want ErrEncoding", name, err) + } + } + // A declared type other than TextEq's plaintext, and an extended plan. + wrongKind := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.Uint64, Target: "TextEq"}}} + if err := eqlChecker.Check(ctx, wrongKind); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("a uint64 TextEq field: Check = %v, want ErrEncoding", err) + } + extended := &record.Plan{Context: []string{"users"}, Extension: []any{uint64(7)}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := eqlChecker.Check(ctx, extended); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("an extended plan with a target field: Check = %v, want ErrEncoding", err) + } +} + +func TestExtendedCipherRefusesATextEqField(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + tenant := c.DefaultKeyset().Extend(uint64(7)) + _, err := testusers.EncryptContact(ctx, tenant, contacts[:1]) + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("an extended cipher sealed a TextEq field: %v", err) + } +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index e3322e275..40e74cd4d 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -77,11 +77,36 @@ var ErrDeterministicGuestNotBuilt = errors.New("encrypt: deterministic guest not // of the guest, seeded: every key derives from the seed and the context, so // it opens what the Rust record fixture sealed under the same seed and // needs no ZeroKMS. ErrDeterministicGuestNotBuilt when the build is absent. +// This is the build WITHOUT the EQL types, whatever is linked: the tests +// name the build they run against (NewDeterministicEQLClient is the other), +// so each build keeps its own coverage. func NewDeterministicClient(ctx context.Context, seed [32]byte) (*Client, error) { wasm, err := os.ReadFile(deterministicGuestPath) if err != nil { return nil, ErrDeterministicGuestNotBuilt } + return deterministicClient(ctx, wasm, seed) +} + +// ErrDeterministicEQLGuestNotBuilt says the test build with the EQL types +// is absent, or package eql is not linked. +var ErrDeterministicEQLGuestNotBuilt = errors.New("encrypt: deterministic eql guest not built; run `mise run wasm:guest:build:eql:deterministic`") + +// deterministicEQLGuestPath is the test build WITH the EQL types, which +// `mise run wasm:guest:build:eql:deterministic` writes under testdata too. +const deterministicEQLGuestPath = "testdata/stack_encrypt_guest_eql_deterministic.wasm" + +// NewDeterministicEQLClient is NewDeterministicClient over the test build +// WITH the EQL types. +func NewDeterministicEQLClient(ctx context.Context, seed [32]byte) (*Client, error) { + wasm, err := os.ReadFile(deterministicEQLGuestPath) + if err != nil { + return nil, ErrDeterministicEQLGuestNotBuilt + } + return deterministicClient(ctx, wasm, seed) +} + +func deterministicClient(ctx context.Context, wasm []byte, seed [32]byte) (*Client, error) { tr := &transport{rt: refusingTransport{}, token: noToken{}} inst, err := newInstance(ctx, wasm, tr, guest.BestEffort) if err != nil { @@ -122,9 +147,32 @@ func RawClient(t *testing.T, wasm []byte) *Client { return c } -// EmbeddedGuest is the embedded guest's bytes, or ErrGuestNotBuilt. +// EmbeddedGuest is the guest a client runs: the build with the EQL types +// when package eql is linked (it is, by the generated test types), else +// this package's own. ErrGuestNotBuilt when absent. func EmbeddedGuest() ([]byte, error) { return embeddedGuest() } +// PlainGuest is this package's own embedded guest, the build without the +// EQL types, whatever is linked. ErrGuestNotBuilt when absent. +func PlainGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// NewCheckerOver is NewChecker over the given guest bytes, so a test asks a +// named build its questions. +func NewCheckerOver(ctx context.Context, wasm []byte) (*Checker, error) { + t := &transport{rt: refusingTransport{}, token: noToken{}} + inst, err := newInstance(ctx, wasm, t, guest.BestEffort) + if err != nil { + return nil, err + } + return &Checker{c: newClient(inst, t)}, nil +} + // LiveClient is liveClient for the external tests: a client against real // ZeroKMS from the STACK_ENCRYPT_TEST_* variables, or a skip. func LiveClient(t *testing.T) *Client { return liveClient(t) } diff --git a/languages/golang/encrypt/gensupport/codec.go b/languages/golang/encrypt/gensupport/codec.go index a4cc56a74..4330cf972 100644 --- a/languages/golang/encrypt/gensupport/codec.go +++ b/languages/golang/encrypt/gensupport/codec.go @@ -14,9 +14,9 @@ import ( // what Value reads. Passthrough fields are in it too. type Values map[string]any -// Output is what one field became: the passthrough value, or the ciphertext -// and each term the field declares. EQL is the EQL value of an encrypt_into -// field, once the engine produces one. +// Output is what one field became: the passthrough value, the ciphertext +// and each term the field declares, or EQL, the EQL value of an encrypt_into +// field as the JSON bytes its column holds. type Output struct { Value any Ciphertext encrypt.Ciphertext @@ -157,7 +157,7 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte case !ok: return nil, fmt.Errorf("gensupport: %s: value %d: Open gave no field %q", c.g.TypeName, i, f.name) case f.sealed(): - records[i][f.name] = record.Outputs{Ciphertext: o.Ciphertext} + records[i][f.name] = record.Outputs{Ciphertext: o.Ciphertext, EQL: o.EQL} default: passthrough[i][f.name] = o.Value } @@ -217,7 +217,7 @@ func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error) } func outputOf(o record.Outputs) Output { - out := Output{Ciphertext: o.Ciphertext} + out := Output{Ciphertext: o.Ciphertext, EQL: o.EQL} for k, term := range o.Terms { switch k { case record.Equality: diff --git a/languages/golang/encrypt/gensupport/declaration.go b/languages/golang/encrypt/gensupport/declaration.go index 34377ac10..e6a30baa3 100644 --- a/languages/golang/encrypt/gensupport/declaration.go +++ b/languages/golang/encrypt/gensupport/declaration.go @@ -113,7 +113,9 @@ func (d Declaration) Index(name string, kind Kind, indexes ...encrypt.Index) Dec return d.add(field{name: name, kind: kind, verb: verbIndex, indexes: indexes}) } -// EncryptInto seals the field into one EQL value of the named type. +// EncryptInto seals the field into one EQL value of the named type, such as +// TextEq: the type's own plan decides what is sealed and which terms sit +// beside it, and the stored field holds the value as it is. func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declaration { return d.add(field{name: name, kind: kind, verb: verbEncryptInto, eqlType: eqlType}) } @@ -165,6 +167,10 @@ func (d Declaration) add(f field) Declaration { d.err = fmt.Errorf("gensupport: field %q: an indexed field names at least one index", f.name) return d } + if f.verb == verbEncryptInto && f.eqlType == "" { + d.err = fmt.Errorf("gensupport: field %q: EncryptInto names an EQL type", f.name) + return d + } // Clip, so the append copies and never writes into an array that an // earlier Declaration shares. d.fields = append(slices.Clip(d.fields), f) @@ -197,9 +203,11 @@ func (d Declaration) plan() (*record.Plan, error) { rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind)} switch f.verb { case verbEncryptInto: - // The next build of the engine lists its EQL types through - // se_targets; until then no declaration can seal into one. - return nil, fmt.Errorf("gensupport: field %q: EQL types are not available yet", f.name) + // The EQL type's own plan decides the outputs; the engine + // returns the finished value. Whether the engine holds the type + // is the engine's to say, at the first call (stashgen asked it + // when the file was written). + rf.Target = f.eqlType case verbEncrypt: rf.Outputs = []record.Output{record.Ciphertext} case verbEncryptIndex: diff --git a/languages/golang/encrypt/gensupport/field.go b/languages/golang/encrypt/gensupport/field.go index 09a4d1326..6b3edf5e4 100644 --- a/languages/golang/encrypt/gensupport/field.go +++ b/languages/golang/encrypt/gensupport/field.go @@ -55,13 +55,21 @@ func (f Field[T]) Encrypt(ctx context.Context, c *encrypt.Cipher, v T) (Output, return outputOf(sealed[0][f.name]), nil } -// Query derives the EQL query value for one value of an encrypt_into field. -// No EQL type is available in this build of the engine, so it fails. -func (f Field[T]) Query(context.Context, *encrypt.Cipher, T) (Output, error) { +// Query derives the EQL query value for one value of an encrypt_into field: +// the operand that matches stored values of the field, in Output.EQL. The +// engine runs the EQL type's own query plan, with no data key. +func (f Field[T]) Query(ctx context.Context, c *encrypt.Cipher, v T) (Output, error) { if f.err != nil { return Output{}, f.err } - return Output{}, fmt.Errorf("gensupport: %s: EQL types are not available yet", f.name) + if c == nil { + return Output{}, fmt.Errorf("gensupport: %s: Query needs a cipher", f.name) + } + out, err := c.Query(ctx, f.plan, f.name, v) + if err != nil { + return Output{}, err + } + return Output{EQL: out}, nil } // Equality derives the field's equality term for one value. diff --git a/languages/golang/encrypt/gensupport/gensupport_internal_test.go b/languages/golang/encrypt/gensupport/gensupport_internal_test.go index 059348b92..72a40f043 100644 --- a/languages/golang/encrypt/gensupport/gensupport_internal_test.go +++ b/languages/golang/encrypt/gensupport/gensupport_internal_test.go @@ -69,7 +69,7 @@ func TestDeclarationRefusals(t *testing.T) { "opaque with another field": DeclareOpaque("u").Encrypt("a", String), "identity of no field": Declare("u").Encrypt("a", String).Identity("b", "x"), "seals nothing": Declare("u").Passthrough("id"), - "EQL not available": Declare("u").EncryptInto("email", String, "TextEq"), + "EQL type unnamed": Declare("u").EncryptInto("email", String, ""), "json index": Declare("u").EncryptIndex("a", String, encrypt.JSON()), "name not a label": Declare("u").Encrypt("1a", String), } @@ -78,9 +78,16 @@ func TestDeclarationRefusals(t *testing.T) { t.Errorf("%s: accepted", name) } } - if _, err := Declare("u").EncryptInto("email", String, "TextEq").plan(); err == nil || !strings.Contains(err.Error(), "EQL types are not available yet") { + // encrypt_into lowers to the plan's target form: the EQL type's name, + // no outputs. Whether the engine holds the type is the engine's answer, + // at the first call. + p, err := Declare("u").EncryptInto("email", String, "TextEq").plan() + if err != nil { t.Fatalf("encrypt_into: %v", err) } + if f := p.Field("email"); f == nil || f.Target != "TextEq" || len(f.Outputs) != 0 || f.Kind != record.String { + t.Fatalf("encrypt_into lowered to %+v", p.Fields) + } } func TestConvertStaysWithinAFamily(t *testing.T) { diff --git a/languages/golang/encrypt/guest.go b/languages/golang/encrypt/guest.go index 59096fb9f..cddc1bade 100644 --- a/languages/golang/encrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -8,6 +8,7 @@ import ( "fmt" "sync" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/eqlguest" "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -20,6 +21,11 @@ import ( // directory so the package compiles without it; NewClient reports its // absence. // +// This is the build without the EQL types. Package eql embeds the build +// with them and registers it on import (internal/eqlguest), so a program +// whose generated code names an EQL type runs that build instead; see +// embeddedGuest. +// //go:embed wasm var guestFS embed.FS @@ -29,7 +35,24 @@ const guestPath = "wasm/stack_encrypt_guest.wasm" // embedded. var ErrGuestNotBuilt = errors.New("encrypt: guest module not built — run `mise run wasm:guest:build`") +// ErrEQLGuestNotBuilt is returned by NewClient when the program links +// package eql (its generated code names an EQL type) and the guest build +// with the EQL types is not embedded. There is no fallback to the build +// without them: every encrypt_into call would fail there, so the program +// fails at startup instead. +var ErrEQLGuestNotBuilt = errors.New("encrypt: guest module with the EQL types not built — run `mise run wasm:guest:build:eql`") + +// embeddedGuest is the guest a client runs: the build with the EQL types +// when package eql is linked (it registers on import, which cannot fail), +// and only that build; else this package's own. func embeddedGuest() ([]byte, error) { + if eqlguest.Linked() { + wasm := eqlguest.Module() + if wasm == nil { + return nil, ErrEQLGuestNotBuilt + } + return wasm, nil + } wasm, err := guestFS.ReadFile(guestPath) if err != nil { return nil, ErrGuestNotBuilt @@ -61,7 +84,7 @@ type instance struct { exports guest.Exports cipherInit, shutdown, keyset api.Function - term api.Function + term, query api.Function encryptRecord, decryptRecord api.Function planCheck, targets api.Function } @@ -148,6 +171,7 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo "se_shutdown": &inst.shutdown, "se_keyset": &inst.keyset, "se_term": &inst.term, + "se_query": &inst.query, "se_encrypt_record": &inst.encryptRecord, "se_decrypt_record": &inst.decryptRecord, "se_plan_check": &inst.planCheck, diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 94aff4e53..b64caaf00 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -682,6 +682,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.18.0" @@ -691,6 +697,23 @@ dependencies = [ "serde", ] +[[package]] +name = "eql-bindings" +version = "3.0.6" +dependencies = [ + "base64", + "hex", + "schemars", + "serde", + "serde_json", + "stack-encrypt", + "thiserror 2.0.20", + "ts-rs", + "vitaminc-aead-value", + "vitaminc-prf", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1208,6 +1231,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + [[package]] name = "libc" version = "0.2.189" @@ -1677,6 +1706,26 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "regex" version = "1.13.1" @@ -1759,6 +1808,31 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.4", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1821,6 +1895,17 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2012,6 +2097,7 @@ version = "0.0.0" dependencies = [ "base16ct", "base64ct", + "eql-bindings", "futures", "recipher", "serde", @@ -2133,6 +2219,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2288,6 +2383,29 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ts-rs" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" +dependencies = [ + "lazy_static", + "thiserror 2.0.20", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9d8656589772eeec2cf7a8264d9cda40fb28b9bc53118ceb9e8c07f8f38730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + [[package]] name = "typenum" version = "1.20.1" @@ -2699,6 +2817,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/languages/golang/encrypt/guest/Cargo.toml b/languages/golang/encrypt/guest/Cargo.toml index 3d47e469d..ef9814ecf 100644 --- a/languages/golang/encrypt/guest/Cargo.toml +++ b/languages/golang/encrypt/guest/Cargo.toml @@ -31,6 +31,16 @@ crate-type = ["cdylib", "rlib"] # hermetic round trips; `mise run wasm:guest:build:deterministic` builds it # beside the real guest. Never the embedded guest. deterministic-kms = ["stack-kms/test-support"] +# The guest build that holds the EQL types (ADR-0007, amended 2026-10-06): +# links eql-bindings and installs its by-name dispatch as the engine's +# `TargetResolver`, so a plan field may name an EQL type (`TextEq`) and +# `se_encrypt_record` returns the finished EQL value; `se_targets` lists the +# catalog. Off, the guest installs `NoTargets` and refuses every target name +# at `se_plan_check`. The Go module embeds both builds: `encrypt` the one +# without, `encrypt/eql` the one with, registered on import +# (`mise run wasm:guest:build:eql`). Combines with `deterministic-kms` for +# the hermetic Go tests of a TextEq field. +eql = ["dep:eql-bindings"] [dependencies] # The stack crates with default features off: no reqwest, no native TLS — @@ -45,6 +55,10 @@ stack-encrypt = { path = "../../../../packages/stack-encrypt", default-features # This crate defines only the exports that are its own. stack-guest-abi = { path = "../../../../packages/stack-guest-abi" } stack-kms = { path = "../../../../packages/stack-kms", default-features = false } +# The EQL types, for the `eql` build only. By path, like every other +# eql-bindings dependency in the repository: the SQL that stores a payload +# and the Rust that emits it must agree (scripts/lint-no-eql-registry-pins.mjs). +eql-bindings = { path = "../../../../packages/eql/crates/eql-bindings", features = ["stack-encrypt"], optional = true } zerokms-protocol = "=0.12.31" # The FFI codec + `FfiValue` tree, shared with vitaminc's own guest — one diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index 3b5fd1e7d..c3ef6b43c 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -6,7 +6,7 @@ //! //! - **Every export handed plaintext wipes that buffer in place before it //! returns**, rather than leaving it for `se_dealloc`: [`se_cipher_init`] -//! (the config carries the client key), and [`se_term`] and +//! (the config carries the client key), and [`se_term`], [`se_query`] and //! [`se_encrypt_record`] (their value/source buffers). The host's plaintext therefore lives no longer //! than the call, instead of until the host gets round to releasing it. //! **A host must not read a plaintext input buffer back after the call, or @@ -443,8 +443,47 @@ pub unsafe extern "C" fn se_plan_check(plan_ptr: *const u8, plan_len: u32) -> u6 .map_or_else(err_status, ok_buffer) } -/// The EQL types this build produces, as a codec-encoded -/// `{"targets": [...]}` ([`ops::targets`]). Needs no cipher. +/// Derive the EQL query value of one target field under the keyset `opts` +/// selects: a codec-encoded plaintext, the codec-encoded plan and the +/// field's name (UTF-8 bytes) in, the query value's JSON bytes out +/// ([`ops::query`]). The plan is parsed against this build's resolver, so +/// a build without EQL types refuses it as `STATUS_ENCODING` before the +/// cipher is consulted, as it refuses the same plan at [`se_plan_check`]. +/// +/// # Safety +/// +/// As for [`se_term`]. +#[no_mangle] +pub unsafe extern "C" fn se_query( + val_ptr: *mut u8, + val_len: u32, + plan_ptr: *const u8, + plan_len: u32, + field_ptr: *const u8, + field_len: u32, + opt_ptr: *const u8, + opt_len: u32, +) -> u64 { + catch_unwind(AssertUnwindSafe(|| { + let value = unsafe { take_plaintext(val_ptr, val_len)? }; + let value = value.as_slice(); + // SAFETY: host-owned ranges the export was handed; the borrows end + // before it returns and before any wipe of an overlapping range. + let plan = unsafe { input(plan_ptr, plan_len)? }; + let field = unsafe { input(field_ptr, field_len)? }; + let opts = unsafe { input(opt_ptr, opt_len)? }; + ops::validate::query(value, plan, field)?; + with_keyset(opts, |keyset| { + block_on(ops::query(keyset, value, plan, field)) + }) + })) + .unwrap_or(Err(STATUS_INTERNAL)) + .map_or_else(err_status, ok_buffer) +} + +/// The EQL types this build knows, as a codec-encoded +/// `{"targets": [...]}` ([`ops::targets`]): the catalog in the `eql` build, +/// nothing in the other. Needs no cipher. #[no_mangle] pub extern "C" fn se_targets() -> u64 { catch_unwind(AssertUnwindSafe(ops::targets)) diff --git a/languages/golang/encrypt/guest/src/lib.rs b/languages/golang/encrypt/guest/src/lib.rs index 711473d24..dfe7fbbdb 100644 --- a/languages/golang/encrypt/guest/src/lib.rs +++ b/languages/golang/encrypt/guest/src/lib.rs @@ -45,10 +45,16 @@ //! `se_cipher_init`; derived data keys and index keys never leave. //! //! The exports are the record path (`se_encrypt_record`, `se_decrypt_record`), -//! per-field term derivation (`se_term`), keyset resolution (`se_keyset`), -//! the generator's two questions (`se_plan_check`, `se_targets`) and the -//! lifetime pair (`se_cipher_init`, `se_shutdown`). There is no whole-value -//! export: the Go SDK seals every value under a declaration (ADR-0007). +//! per-field term derivation (`se_term`), a target field's EQL query value +//! (`se_query`), keyset resolution (`se_keyset`), the generator's two +//! questions (`se_plan_check`, `se_targets`) and the lifetime pair +//! (`se_cipher_init`, `se_shutdown`). There is no whole-value export: the +//! Go SDK seals every value under a declaration (ADR-0007). +//! +//! The crate has two builds (ADR-0007, amended 2026-10-06): with the `eql` +//! feature it links `eql-bindings` and a plan field may name an EQL type as +//! its target ([`targets`]); without it, a plan that does is refused at +//! `se_plan_check`. //! //! One instance is one client: `se_cipher_init` runs once per instance and //! the keysets that client uses are selected per call through the options @@ -60,7 +66,7 @@ //! Split into: //! //! - [`ops`], [`options`], [`config`], [`response`], [`headers`], -//! [`status`] — everything that is pure logic over +//! [`status`], [`targets`] — everything that is pure logic over //! `StackCipher` / `KeysetCipher` / bytes. Compiles and unit-tests //! on the native host target (`cargo test` here, no wasm toolchain //! needed) against `stack_kms::FakeDataKeySource`. @@ -85,6 +91,7 @@ pub mod ops; pub mod options; pub mod response; pub mod status; +pub mod targets; // The ABI's packed u64 results embed 32-bit pointers, its bounds checks // read the wasm linear-memory size, and `host` calls imported functions — diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index a233eaf1b..87409e254 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -44,6 +44,7 @@ use vitaminc_aead_value::{transport as codec, FfiValue}; use vitaminc_protected::Controlled; use crate::status::{status_for_dynamic, status_for_error, STATUS_ENCODING, STATUS_INTERNAL}; +use crate::targets::resolver; /// Term kinds for `se_term`, part of the guest/host contract (the Go host /// mirrors these values). @@ -153,8 +154,8 @@ pub async fn encrypt_record( where K: DataKeySource + Sync + 'static, { - let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; - let tree = dynamic::record::encrypt(cipher, decode_value(source)?, &plan) + let plan = parse_plan(plan)?; + let tree = dynamic::record::encrypt_with(cipher, decode_value(source)?, &plan, &resolver()) .map_err(|e| status_for_dynamic(&e))? .await .map_err(|e| status_for_error(&e))?; @@ -178,14 +179,45 @@ pub async fn decrypt_record( where K: DataKeySource + Sync + 'static, { - let plan = dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; - let value = dynamic::record::decrypt(scope, decode_tree(record)?, &plan) + let plan = parse_plan(plan)?; + let value = dynamic::record::decrypt_with(scope, decode_tree(record)?, &plan, &resolver()) .map_err(|e| status_for_dynamic(&e))? .await .map_err(|e| status_for_error(&e))?; encode_value(value) } +/// Derive the EQL query value of one target field: a codec-encoded +/// plaintext, the codec-encoded plan and the field's name (UTF-8) in, the +/// query value's JSON bytes out — the operand `eql_v3.query_` takes. +/// Runs the EQL type's own query plan through this build's resolver +/// ([`crate::targets`]); a build without EQL types refuses the plan before +/// this is reached. No data key is minted: under the local backend the +/// equality term is one PRF derivation. +pub async fn query( + cipher: &KeysetCipher<'_, K>, + value: &[u8], + plan: &[u8], + field: &[u8], +) -> Result, u32> +where + K: DataKeySource + Sync + 'static, +{ + let plan = parse_plan(plan)?; + let field = std::str::from_utf8(field).map_err(|_| STATUS_ENCODING)?; + dynamic::record::query(cipher, &plan, field, decode_value(value)?, &resolver()) + .map_err(|e| status_for_dynamic(&e))? + .await + .map_err(|e| status_for_error(&e)) +} + +/// A codec-encoded plan, parsed against this build's resolver: a target +/// name this build cannot run is refused here, the same way at every +/// export that takes a plan. +fn parse_plan(plan: &[u8]) -> Result { + dynamic::record::plan_with(decode_value(plan)?, &resolver()).map_err(|e| status_for_dynamic(&e)) +} + // ============================================================================= // The generator's questions // ============================================================================= @@ -198,16 +230,17 @@ where /// declaration it writes, so it holds no copy of the engine's rules; it /// asks one field at a time to name the field that failed. pub fn plan_check(plan: &[u8]) -> Result<(), u32> { - dynamic::record::plan(decode_value(plan)?) - .map(drop) - .map_err(|e| status_for_dynamic(&e)) + parse_plan(plan).map(drop) } -/// The EQL types this build of the engine produces, as a codec-encoded -/// `{"targets": [...]}`. Empty until the EQL target dispatch lands: a -/// generator reads an empty list as "no `encrypt_into` type is available -/// yet" and refuses the tag. The shape is fixed here so the next build adds -/// entries to the list rather than a second export. +/// The EQL types this build of the engine knows, as a codec-encoded +/// `{"targets": [...]}`: one entry per type in +/// [`TargetDescriptor::to_value`](stack_encrypt::dynamic::TargetDescriptor::to_value)'s +/// wire form, producible or not, with the reason when not. The build +/// without EQL types lists none, which a generator reads as "no +/// `encrypt_into` type is available" and refuses the tag; the `eql` build +/// lists the catalog, and a generator writes `encrypt_into` only for an +/// entry whose `producible` is true. /// /// **Each entry is wire format**, the serialisation of eql-bindings' target /// record, and the Go reader (`encrypt.parseTargets`) refuses an entry with @@ -226,9 +259,16 @@ pub fn plan_check(plan: &[u8]) -> Result<(), u32> { /// | `producible` | bool | whether this build produces the type | /// | `reason` | string or null | why not, when `producible` is false | pub fn targets() -> Result, u32> { + use stack_encrypt::dynamic::TargetResolver as _; encode_value(FfiValue::Object(vec![( "targets".to_string(), - FfiValue::Array(Vec::new()), + FfiValue::Array( + resolver() + .targets() + .iter() + .map(|target| target.to_value()) + .collect(), + ), )])) } @@ -262,18 +302,34 @@ pub mod validate { /// output known), and the source fits it (shape, field set, each value /// against its field's outputs). pub fn record(source: &[u8], plan: &[u8]) -> Result<(), u32> { - let plan = - dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let plan = parse_plan(plan)?; dynamic::record::check_source(decode_value(source)?, &plan) .map_err(|e| status_for_dynamic(&e)) } + /// A target query's inputs, as [`query`] takes them: the plan parses + /// against this build's resolver, the field is one of its target fields, + /// and the value is of the field's declared kind. + pub fn query(value: &[u8], plan: &[u8], field: &[u8]) -> Result<(), u32> { + let plan = parse_plan(plan)?; + let field = std::str::from_utf8(field).map_err(|_| STATUS_ENCODING)?; + let field = plan + .fields() + .iter() + .find(|candidate| candidate.name() == field && candidate.target().is_some()) + .ok_or(STATUS_ENCODING)?; + let value = decode_value(value)?; + if field.field_type().is_some_and(|kind| !kind.holds(&value)) { + return Err(STATUS_ENCODING); + } + Ok(()) + } + /// A record tree against its plan, as [`decrypt_record`] takes them: /// the plan parses, the tree decodes with well-formed leaves, and every /// ciphertext-bearing field has a `"c"` node that is not a passthrough. pub fn record_tree(record: &[u8], plan: &[u8]) -> Result<(), u32> { - let plan = - dynamic::record::plan(decode_value(plan)?).map_err(|e| status_for_dynamic(&e))?; + let plan = parse_plan(plan)?; dynamic::record::check_record(decode_tree(record)?, &plan) .map_err(|e| status_for_dynamic(&e)) } @@ -496,4 +552,212 @@ mod tests { assert_eq!(out.len(), expected, "tree {i}"); } } + + // ---- the EQL target path, native, over FakeDataKeySource ----------------- + + fn encoded(value: FfiValue) -> Vec { + let mut out = Vec::new(); + codec::encode_value(value, &mut out).expect("encode"); + out + } + + fn text(value: &str) -> FfiValue { + FfiValue::String(value.into()) + } + + fn object(entries: Vec<(&str, FfiValue)>) -> FfiValue { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + } + + /// `email` named as a `TextEq` target under users/email, beside a + /// sealed `notes`. + fn target_plan() -> Vec { + encoded(object(vec![ + ( + "email", + object(vec![ + ( + "context", + FfiValue::Array(vec![text("users"), text("email")]), + ), + ("target", text("TextEq")), + ("type", text("string")), + ]), + ), + ( + "notes", + object(vec![ + ( + "context", + FfiValue::Array(vec![text("users"), text("notes")]), + ), + ("outputs", FfiValue::Array(vec![text("c")])), + ("type", text("string")), + ]), + ), + ])) + } + + #[test] + fn targets_lists_what_this_build_holds_in_the_wire_shape() { + let out = targets().expect("encodes"); + let FfiValue::Object(entries) = decode_value(&out).expect("decodes") else { + panic!("an object"); + }; + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].0, "targets"); + let FfiValue::Array(items) = &entries[0].1 else { + panic!("a list"); + }; + if !crate::targets::HOLDS_EQL { + assert!(items.is_empty(), "the build without EQL types lists none"); + return; + } + let FfiValue::Object(text_eq) = items + .iter() + .find(|item| { + matches!(item, FfiValue::Object(fields) if fields.iter().any(|(k, v)| k == "name" && matches!(v, FfiValue::String(s) if s.risky_ref() == b"TextEq"))) + }) + .expect("TextEq is listed") + else { + panic!("an object") + }; + let keys: Vec<&str> = text_eq.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + keys, + [ + "name", + "family", + "suffix", + "plaintext", + "sql_domain", + "indexes", + "query", + "query_sql_domain", + "producible", + "reason" + ] + ); + assert!(matches!(text_eq[8].1, FfiValue::Bool(true))); + } + + #[test] + fn a_plan_naming_a_target_is_accepted_exactly_by_the_eql_build() { + let plan = target_plan(); + if crate::targets::HOLDS_EQL { + assert_eq!(plan_check(&plan), Ok(()), "the eql build runs TextEq"); + // A type the engine cannot produce, and one that does not exist, + // are refused at plan_check. + for name in ["TextOrdOre", "Nope"] { + let refused = encoded(object(vec![( + "email", + object(vec![ + ( + "context", + FfiValue::Array(vec![text("users"), text("email")]), + ), + ("target", text(name)), + ]), + )])); + assert_eq!(plan_check(&refused), Err(STATUS_ENCODING), "{name}"); + } + } else { + assert_eq!( + plan_check(&plan), + Err(STATUS_ENCODING), + "the build without EQL types refuses a target name" + ); + } + } + + #[cfg(feature = "eql")] + #[test] + fn a_text_eq_field_round_trips_and_queries_through_the_record_exports() { + use futures::executor::block_on; + use stack_encrypt::StackCipher; + use stack_kms::FakeDataKeySource; + + let cipher = block_on(StackCipher::builder().kms(FakeDataKeySource::new()).init()) + .expect("a cipher"); + let keyset = cipher.default_keyset(); + let plan = target_plan(); + let source = encoded(object(vec![ + ("email", text("alice@example.com")), + ("notes", text("likes cats")), + ])); + validate::record(&source, &plan).expect("the source fits"); + let sealed = block_on(encrypt_record(&keyset, &source, &plan)).expect("seals"); + + // The stored tree carries the EQL JSON under "eql": the v3 envelope + // with the column as its i and a stack-encrypt ciphertext. + let tree = decode_tree(&sealed).expect("a tree"); + let CipherText::Map(mut fields) = tree else { + panic!("a record") + }; + let at = fields + .iter() + .position(|(k, _)| k == "email") + .expect("email"); + let (_, CipherText::Map(outputs)) = fields.swap_remove(at) else { + panic!("outputs") + }; + assert_eq!(outputs.len(), 1); + assert_eq!(outputs[0].0, dynamic::record::EQL_KEY); + let CipherText::Passthrough(payload) = &outputs[0].1 else { + panic!("a passthrough") + }; + let FfiValue::Bytes(bytes) = payload.downcast_ref::().expect("a value") else { + panic!("bytes") + }; + let eql: serde_json::Value = serde_json::from_slice(bytes.risky_ref()).expect("JSON"); + assert_eq!(eql["v"], 3); + assert_eq!(eql["i"], serde_json::json!({"t": "users", "c": "email"})); + assert!(eql["c"].as_str().unwrap().starts_with("stack-encrypt:1:")); + assert_eq!(eql["hm"].as_str().unwrap().len(), 64); + + // Opens back through the EQL type's own decryption. + validate::record_tree(&sealed, &plan).expect("the tree fits"); + let opened = + block_on(decrypt_record(Scope::Client(&cipher), &sealed, &plan)).expect("opens"); + let FfiValue::Object(values) = decode_value(&opened).expect("a value") else { + panic!("an object") + }; + assert!( + matches!(&values[0].1, FfiValue::String(s) if s.risky_ref() == b"alice@example.com") + ); + assert!(matches!(&values[1].1, FfiValue::String(s) if s.risky_ref() == b"likes cats")); + + // The query value matches the stored term. + let probe = encoded(text("alice@example.com")); + validate::query(&probe, &plan, b"email").expect("a target field"); + let query_bytes = block_on(query(&keyset, &probe, &plan, b"email")).expect("derives"); + let probe_json: serde_json::Value = serde_json::from_slice(&query_bytes).expect("JSON"); + assert_eq!( + probe_json["hm"], eql["hm"], + "the query matches the stored value" + ); + assert!( + probe_json.get("c").is_none(), + "a query carries no ciphertext" + ); + // A query on a sealed field, an unknown field, or a wrong kind is + // refused at validation. + assert_eq!( + validate::query(&probe, &plan, b"notes"), + Err(STATUS_ENCODING) + ); + assert_eq!( + validate::query(&probe, &plan, b"nope"), + Err(STATUS_ENCODING) + ); + assert_eq!( + validate::query(&encoded(FfiValue::UInt32(1)), &plan, b"email"), + Err(STATUS_ENCODING) + ); + } } diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 4647f1634..61b38fe3f 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -108,6 +108,12 @@ pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { STATUS_ENCODING } Error::Cipher(e) => status_for_error(e), + // A target refusal is a statement about the plan, the label or the + // value (an unknown or unproducible type, an extended plan, a value + // of another kind, stored bytes that are not the type) — malformed + // input, like the rest — save the resolver's own failure. + Error::Target(stack_encrypt::dynamic::TargetError::Other(_)) => STATUS_INTERNAL, + Error::Target(_) => STATUS_ENCODING, Error::Internal => STATUS_INTERNAL, _ => STATUS_INTERNAL, } @@ -331,6 +337,69 @@ mod tests { ); } + /// The two `Error::Target` arms, in order: every target refusal is the + /// caller's input, and the resolver's own failure is never reported as + /// such. Swapping the arms, or dropping the `Other` one, fails here. + #[test] + fn target_refusals_are_encoding_and_a_resolver_failure_is_internal() { + use stack_encrypt::dynamic::{Error, TargetError}; + use vitaminc_aead_value::ValueKind; + let refusals = [ + TargetError::NoTargets { + name: "TextEq".into(), + }, + TargetError::Unknown { + name: "Nope".into(), + }, + TargetError::Unproducible { + name: "TextOrdOre".into(), + reason: "block ORE".into(), + }, + TargetError::Extended { + name: "email".into(), + label: "users/email".into(), + }, + TargetError::NoQuery { + name: "Text".into(), + }, + TargetError::Kind { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + declared: ValueKind::UInt64, + }, + TargetError::Column { + name: "email".into(), + label: "app/users/email".into(), + reason: "two segments".into(), + }, + TargetError::Plaintext { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + found: None, + }, + TargetError::Stored { + name: "email".into(), + target: "TextEq".into(), + reason: "not JSON".into(), + }, + ]; + for refusal in refusals { + let label = refusal.to_string(); + assert_eq!( + status_for_dynamic(&Error::Target(refusal)), + STATUS_ENCODING, + "{label}: a target refusal is the caller's input" + ); + } + assert_eq!( + status_for_dynamic(&Error::Target(TargetError::Other("boom".into()))), + STATUS_INTERNAL, + "the resolver's own failure is never the caller's input" + ); + } + #[test] fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { use stack_encrypt::dynamic::Error; diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs new file mode 100644 index 000000000..4b344c99c --- /dev/null +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -0,0 +1,210 @@ +//! The EQL types this build holds: the `TargetResolver` +//! (`stack_encrypt::dynamic::TargetResolver`) the record operations run +//! target fields through. The two names below are cfg-dependent, so they +//! are code spans, not links: a doc build of either feature set resolves. +//! +//! Two builds of this crate, one resolver each (ADR-0007, amended +//! 2026-10-06). With the `eql` feature, [`Resolver`] is `EqlTargets`, +//! which installs `eql-bindings`' by-name dispatch +//! (`eql_bindings::encryption::targets`): `se_targets` lists every EQL type +//! the catalog has, and a plan field naming a producible one (`TextEq`) is +//! run through that type's own `EncryptFrom` / `DecryptInto`, in the same +//! ZeroKMS request as the rest of the record. Without it, [`Resolver`] is +//! the engine's `NoTargets` (`stack_encrypt::dynamic::NoTargets`): +//! `se_targets` lists nothing, and a plan that +//! names a target is refused when it is parsed — at `se_plan_check`, before +//! any value crosses. +//! +//! The Go module embeds both: `encrypt` the build without EQL types, +//! `encrypt/eql` the build with them, registered on import, so a program +//! that names an EQL type links the build that has it. + +#[cfg(not(feature = "eql"))] +use stack_encrypt::dynamic::NoTargets; +#[cfg(feature = "eql")] +use stack_encrypt::dynamic::{TargetDescriptor, TargetError, TargetResolver}; + +/// The resolver this build installs. +#[cfg(feature = "eql")] +pub type Resolver = EqlTargets; +/// The resolver this build installs. +#[cfg(not(feature = "eql"))] +pub type Resolver = NoTargets; + +/// This build's resolver, for the record operations. +pub fn resolver() -> Resolver { + Resolver::default() +} + +/// Whether this build holds the EQL types. +pub const HOLDS_EQL: bool = cfg!(feature = "eql"); + +/// `eql-bindings`' by-name dispatch as the engine's resolver. The resolver +/// sees a type name and a field's label; the field's name is the +/// lowering's to add to a refusal. +#[cfg(feature = "eql")] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct EqlTargets; + +#[cfg(feature = "eql")] +impl TargetResolver for EqlTargets { + fn targets(&self) -> Vec { + eql_bindings::encryption::targets::targets() + .iter() + .map(describe) + .collect() + } + + fn encrypt<'a, K: 'static>( + &self, + name: &str, + keyset: &'a stack_encrypt::KeysetCipher<'_, K>, + label: &stack_encrypt::Label, + plaintext: vitaminc_aead_value::FfiValue, + ) -> Result, K>, TargetError> { + eql_bindings::encryption::targets::encrypt(name, keyset, label, plaintext).map_err(convert) + } + + fn decrypt<'a, K: 'static>( + &self, + name: &str, + cipher: &'a stack_encrypt::StackCipher, + label: &stack_encrypt::Label, + stored: &[u8], + ) -> Result, TargetError> { + eql_bindings::encryption::targets::decrypt(name, cipher, label, stored).map_err(convert) + } + + fn query<'a, K: 'static>( + &self, + name: &str, + keyset: &'a stack_encrypt::KeysetCipher<'_, K>, + label: &stack_encrypt::Label, + plaintext: vitaminc_aead_value::FfiValue, + ) -> Result, K>, TargetError> { + eql_bindings::encryption::targets::query(name, keyset, label, plaintext).map_err(convert) + } +} + +/// An `eql-bindings` table row as the engine's descriptor. The two spell +/// one wire format; `eql-bindings` tests its serde form against this +/// crate's `to_value`. +#[cfg(feature = "eql")] +fn describe(target: &eql_bindings::encryption::targets::Target) -> TargetDescriptor { + TargetDescriptor::new( + target.name, + target.family, + target.suffix, + target.plaintext_kind(), + target.sql_domain, + target.indexes.iter().map(|key| (*key).to_owned()).collect(), + target.query.map(str::to_owned), + target.query_sql_domain.map(str::to_owned), + target.producible, + target.reason.map(str::to_owned), + ) +} + +/// An `eql-bindings` refusal as the engine's. The field name is left empty: +/// the lowering fills it in, since the resolver never sees it. +#[cfg(feature = "eql")] +fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError { + use eql_bindings::encryption::targets::TargetError as Eql; + match error { + Eql::Unknown { name } => TargetError::Unknown { name }, + Eql::Unproducible { name, reason } => TargetError::Unproducible { + name: name.to_owned(), + reason: reason.to_owned(), + }, + Eql::NoQuery { name } => TargetError::NoQuery { + name: name.to_owned(), + }, + Eql::Context { label } => TargetError::Column { + name: String::new(), + label, + reason: "an EQL column is a two-segment label, table and column".to_owned(), + }, + Eql::Plaintext { + target, + expected, + found, + } => TargetError::Plaintext { + name: String::new(), + target: target.to_owned(), + expected: Some(expected), + found, + }, + Eql::Stored { target, source } => TargetError::Stored { + name: String::new(), + target: target.to_owned(), + reason: source.to_string(), + }, + other => TargetError::Other(Box::new(other)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(feature = "eql"))] + #[test] + fn the_build_without_eql_holds_no_target() { + use stack_encrypt::dynamic::TargetResolver as _; + assert!(resolver().targets().is_empty()); + assert!(matches!( + resolver().resolve("TextEq"), + Err(stack_encrypt::dynamic::TargetError::NoTargets { .. }) + )); + } + + #[cfg(feature = "eql")] + #[test] + fn the_eql_build_lists_the_catalog_and_resolves_text_eq() { + let targets = resolver().targets(); + assert!(!targets.is_empty(), "the catalog has types"); + let text_eq = targets.iter().find(|t| t.name == "TextEq").expect("TextEq"); + assert!(text_eq.producible); + assert_eq!( + text_eq.plaintext, + Some(vitaminc_aead_value::ValueKind::String) + ); + assert_eq!(text_eq.indexes, ["eq"]); + assert_eq!(text_eq.query.as_deref(), Some("TextEqQuery")); + assert_eq!(resolver().resolve("TextEq").unwrap().name, "TextEq"); + let producible: Vec<&str> = targets + .iter() + .filter(|t| t.producible) + .map(|t| t.name.as_str()) + .collect(); + assert_eq!(producible, ["TextEq"], "the engine produces TextEq only"); + assert!(matches!( + resolver().resolve("TextOrdOre"), + Err(TargetError::Unproducible { .. }) + )); + assert!(matches!( + resolver().resolve("Nope"), + Err(TargetError::Unknown { .. }) + )); + } + + #[cfg(feature = "eql")] + #[test] + fn eql_refusals_convert_to_the_engines_with_the_field_left_to_the_lowering() { + use eql_bindings::encryption::targets::TargetError as Eql; + assert!(matches!( + convert(Eql::Context { + label: "tenant/users/email".into() + }), + TargetError::Column { name, label, .. } if name.is_empty() && label == "tenant/users/email" + )); + assert!(matches!( + convert(Eql::Plaintext { + target: "TextEq", + expected: vitaminc_aead_value::ValueKind::String, + found: Some(vitaminc_aead_value::ValueKind::UInt64), + }), + TargetError::Plaintext { target, expected: Some(vitaminc_aead_value::ValueKind::String), .. } if target == "TextEq" + )); + } +} diff --git a/languages/golang/encrypt/guest/tests/native_ops.rs b/languages/golang/encrypt/guest/tests/native_ops.rs index 1add14e71..4799104b7 100644 --- a/languages/golang/encrypt/guest/tests/native_ops.rs +++ b/languages/golang/encrypt/guest/tests/native_ops.rs @@ -1287,15 +1287,47 @@ fn plan_check_answers_without_a_cipher() { assert_eq!(ops::plan_check(b"\xff\xff"), Err(STATUS_ENCODING)); } -/// `targets` is the fixed wire shape with no entries until the EQL target -/// dispatch lands. +/// `targets` is the fixed wire shape: `{"targets": [...]}`. The build +/// without EQL types lists none, which the Go generator reads as "no +/// `encrypt_into` type is available"; the `eql` build lists the catalog, +/// `TextEq` producible and every other type with its reason. #[test] -fn targets_is_an_empty_list_for_now() { +fn targets_lists_what_the_build_holds() { let out = ops::targets().expect("targets encode"); let FfiValue::Object(entries) = decode(&out) else { panic!("targets is an object"); }; assert_eq!(entries.len(), 1); assert_eq!(entries[0].0, "targets"); - assert!(matches!(&entries[0].1, FfiValue::Array(items) if items.is_empty())); + let FfiValue::Array(items) = &entries[0].1 else { + panic!("targets is a list"); + }; + if !stack_encrypt_guest::targets::HOLDS_EQL { + assert!(items.is_empty(), "the build without EQL types lists none"); + return; + } + let name_of = |item: &FfiValue| match item { + FfiValue::Object(fields) => fields.iter().find_map(|(k, v)| match (k.as_str(), v) { + ("name", FfiValue::String(s)) => { + std::str::from_utf8(s.risky_ref()).ok().map(str::to_owned) + } + _ => None, + }), + _ => None, + }; + let producible = |item: &FfiValue| match item { + FfiValue::Object(fields) => fields + .iter() + .any(|(k, v)| k == "producible" && matches!(v, FfiValue::Bool(true))), + _ => false, + }; + let names: Vec = items.iter().filter_map(name_of).collect(); + assert_eq!(names.len(), items.len(), "every entry is named"); + assert!(names.iter().any(|n| n == "TextEq")); + let producible: Vec = items + .iter() + .filter(|i| producible(i)) + .filter_map(name_of) + .collect(); + assert_eq!(producible, ["TextEq"], "the engine produces TextEq only"); } diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index 5da718efa..a82caae59 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -597,6 +597,41 @@ func fixtureRecord() record.Sealed { // A record opened under a plan that seals a field it does not carry is // refused on the host, with the field named, before the guest is asked. +// A record whose target field has no EQL value is refused by the host, +// before the guest sees it: the errNoEQL branch, which a change could +// otherwise drop and send a malformed record on. +func TestTargetWithoutEQLIsRefusedBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + plan := &record.Plan{ + Context: []string{"users"}, + Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}, + } + for name, rec := range map[string]record.Sealed{ + "no outputs": {"email": {}}, + "a ciphertext where the EQL value should be": {"email": {Ciphertext: fixtureLeaf}}, + "the field missing": {}, + } { + _, err := c.DefaultKeyset().Open(ctx, plan, []record.Sealed{rec}) + if !errors.Is(err, errNoEQL) || errors.Is(err, ErrState) || !strings.Contains(err.Error(), `field "email"`) { + t.Errorf("%s: Open = %v, want errNoEQL before the guest, naming the field", name, err) + } + } +} + +// Query refuses a field the plan does not have, and a field that names no +// EQL type, before the guest is asked. +func TestQueryRejectsMissingAndNonTargetFieldsBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + for _, field := range []string{"email", "missing"} { + _, err := c.DefaultKeyset().Query(ctx, usersPlan(), field, "a@b.c") + if !errors.Is(err, ErrEncoding) || errors.Is(err, ErrState) || !strings.Contains(err.Error(), field) { + t.Errorf("Query(%q) = %v, want ErrEncoding before the guest, naming the field", field, err) + } + } +} + func TestMismatchedPlanIsRefusedBeforeTheGuest(t *testing.T) { ctx := context.Background() c := rawInstance(t) @@ -715,9 +750,24 @@ func TestPlanCheckAnswersWithoutACipher(t *testing.T) { t.Errorf("plan %d: %v, want ErrEncoding", i, err) } } + // The test binary links encrypt/eql (through the generated test types), + // so the embedded guest is the build with the EQL types: it lists the + // catalog and runs a plan that names TextEq. The build without them is + // asked the same questions in eql_test.go. targets, err := k.Targets(ctx) - if err != nil || len(targets) != 0 { - t.Fatalf("Targets = %v, %v; want none in this build", targets, err) + if err != nil || len(targets) == 0 { + t.Fatalf("Targets = %v, %v; want the catalog in the eql build", targets, err) + } + found := false + for _, target := range targets { + found = found || target.Name == "TextEq" + } + if !found { + t.Fatalf("TextEq is not among the targets: %v", targets) + } + textEq := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := k.Check(ctx, textEq); err != nil { + t.Fatalf("a TextEq field: %v", err) } } diff --git a/languages/golang/encrypt/internal/eqlguest/eqlguest.go b/languages/golang/encrypt/internal/eqlguest/eqlguest.go new file mode 100644 index 000000000..11e001ada --- /dev/null +++ b/languages/golang/encrypt/internal/eqlguest/eqlguest.go @@ -0,0 +1,26 @@ +// Package eqlguest is where package eql hands the guest build with the EQL +// types to package encrypt. Package encrypt embeds the build without them +// and cannot import eql (eql imports encrypt), so eql registers its module +// here on import and encrypt's embeddedGuest reads it first. Registration +// cannot fail: it is two assignments at program start. +package eqlguest + +var ( + linked bool + module []byte +) + +// Register says package eql is linked and installs its guest build, nil +// when the module was not built. Called once, by package eql's init. +func Register(wasm []byte) { + linked = true + module = wasm +} + +// Linked reports whether package eql is linked: the program names an EQL +// type, so it must run the build that holds them and never the other. +func Linked() bool { return linked } + +// Module is the registered eql guest build, or nil when package eql is not +// linked or its module was not built. +func Module() []byte { return module } diff --git a/languages/golang/encrypt/internal/testusers/contact_stash.go b/languages/golang/encrypt/internal/testusers/contact_stash.go new file mode 100644 index 000000000..05869aea8 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/contact_stash.go @@ -0,0 +1,139 @@ +// Code generated by stashgen. DO NOT EDIT. + +package testusers + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// Contact prints its sealed fields in the clear: it has no String or LogValue +// method. Write them, or run stashgen with -redact. + +type EncryptedContact struct { + ID int64 + Email eql.TextEq + Notes EncryptedContactNotes +} + +type EncryptedContactNotes struct { + Ciphertext encrypt.Ciphertext +} + +func (e EncryptedContact) String() string { + return gensupport.Redacted("EncryptedContact", map[string]any{"ID": e.ID}, "Email", "Notes") +} + +func (e EncryptedContact) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Notes") +} + +// Stops compiling when Contact gains, loses, reorders or retypes a field. +var _ = contactShape(Contact{}) + +type contactShape struct { + _ struct{} + ID int64 + Email string + Notes string +} + +var contactDeclaration = gensupport.Declare("users"). + Passthrough("id"). + EncryptInto("email", gensupport.String, "TextEq"). + Encrypt("notes", gensupport.String) + +var contactCodec = gensupport.New(gensupport.Generated[Contact, EncryptedContact]{ + TypeName: "Contact", + Declaration: contactDeclaration, + PrintsPlaintext: true, + Source: func(v Contact) gensupport.Values { + return gensupport.Values{ + "id": v.ID, + "email": v.Email, + "notes": v.Notes, + } + }, + Seal: func(rec gensupport.Record) (EncryptedContact, error) { + var e EncryptedContact + var err error + if e.ID, err = gensupport.Passthrough[int64](rec, "id"); err != nil { + return EncryptedContact{}, err + } + e.Email = eql.TextEq(rec["email"].EQL) + e.Notes = EncryptedContactNotes{Ciphertext: rec["notes"].Ciphertext} + return e, nil + }, + Open: func(e EncryptedContact) gensupport.Record { + return gensupport.Record{ + "id": {Value: e.ID}, + "email": {EQL: e.Email}, + "notes": {Ciphertext: e.Notes.Ciphertext}, + } + }, + Value: func(e EncryptedContact, vals gensupport.Values) (Contact, error) { + var v Contact + var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return Contact{}, err + } + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return Contact{}, err + } + if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { + return Contact{}, err + } + return v, nil + }, +}) + +// EncryptContact seals each Contact, with one ZeroKMS request for each 500 +// sealed values, plus one the first time a keyset is used. The result has one +// element for each input, in the same order. +func EncryptContact(ctx context.Context, cipher *encrypt.Cipher, values []Contact) ([]EncryptedContact, error) { + return contactCodec.Encrypt(ctx, cipher, values) +} + +// DecryptContact opens each EncryptedContact, with one ZeroKMS request for each +// 500 sealed values, plus one the first time a keyset is used. +func DecryptContact(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedContact) ([]Contact, error) { + return contactCodec.Decrypt(ctx, d, encrypted) +} + +var ContactFields = struct { + Email ContactEmailField + Notes ContactNotesField +}{ + Email: ContactEmailField{gensupport.NewField[string](contactDeclaration, "email")}, + Notes: ContactNotesField{gensupport.NewField[string](contactDeclaration, "notes")}, +} + +type ContactEmailField struct { + field gensupport.Field[string] +} + +func (f ContactEmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.TextEq(out.EQL), err +} + +func (f ContactEmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.TextEqQuery(out.EQL), err +} + +type ContactNotesField struct { + field gensupport.Field[string] +} + +func (f ContactNotesField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedContactNotes, error) { + out, err := f.field.Encrypt(ctx, c, v) + return EncryptedContactNotes{Ciphertext: out.Ciphertext}, err +} diff --git a/languages/golang/encrypt/internal/testusers/contacts.go b/languages/golang/encrypt/internal/testusers/contacts.go new file mode 100644 index 000000000..433f91ac1 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/contacts.go @@ -0,0 +1,15 @@ +package testusers + +//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Contact -name Contact + +// Contact is one row with an email stored as an EQL value, the shape the +// cross-language fixture packages/eql/tests/encryption/fixtures/text_eq_query.json +// is derived under (table users, column email). Its generated file imports +// encrypt/eql, so the encrypt test binary links the guest build with the +// EQL types; the tests that want the build without them load it by name. +type Contact struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt_into=TextEq"` + Notes string `stash:"notes,encrypt"` +} diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index 4b4606dd3..ad63651a6 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -143,6 +143,42 @@ func (cph *Cipher) Derive(ctx context.Context, plan *record.Plan, field string, }) } +// Query derives the EQL query value of a field that names an EQL type, for +// one value: the operand that matches stored values of the field, as the +// JSON bytes the field's eql_v3.query_* domain takes. The engine runs the +// EQL type's own query plan; no data key is minted. For generated code. +func (cph *Cipher) Query(ctx context.Context, plan *record.Plan, field string, value any) ([]byte, error) { + p, err := cph.plan(plan) + if err != nil { + return nil, err + } + f := p.Field(field) + if f == nil { + return nil, fmt.Errorf("%w: the plan has no field %q", ErrEncoding, field) + } + if !f.IsTarget() { + return nil, fmt.Errorf("%w: field %q names no EQL type; its terms are derived by index", ErrEncoding, field) + } + encodedValue, err := vcffi.Marshal(value) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEncoding, err) + } + // The probe value is plaintext: its transport copy is wiped once it is + // in the guest. + defer wipe(encodedValue) + encodedPlan, err := vcffi.Marshal(p.Wire()) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEncoding, err) + } + opts, err := vcffi.Marshal(options(cph.keyset)) + if err != nil { + return nil, err + } + return cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.query, buf(encodedValue), buf(encodedPlan), buf([]byte(field)), buf(opts)) + }) +} + // Open decrypts records sealed under the plan by any keyset of this client: // each record is opened under the keyset that sealed it, with one ZeroKMS // request for each 500 sealed values from each keyset. For generated code. @@ -219,6 +255,16 @@ func sealedOf(p *record.Plan, node any) (record.Sealed, error) { o.Ciphertext = leaf continue } + if key == record.EQLKey { + // The EQL value's JSON, as a passthrough byte node like a + // term's; the ciphertext is inside the JSON. + value, err := termBytes(out) + if err != nil { + return nil, fmt.Errorf("field %q: the EQL value: %v", name, err) + } + o.EQL = value + continue + } term, err := termBytes(out) if err != nil { return nil, fmt.Errorf("field %q output %q: %v", name, key, err) @@ -254,19 +300,29 @@ func termBytes(node any) ([]byte, error) { // errNoCiphertext is a stored record missing a field the plan seals. var errNoCiphertext = errors.New("encrypt: the record has no ciphertext for a sealed field") +// errNoEQL is a stored record missing the EQL value of a field that names +// an EQL type. +var errNoEQL = errors.New("encrypt: the record has no EQL value for a field that names an EQL type") + func (c *Client) open(ctx context.Context, sel KeysetSelector, p *record.Plan, records []record.Sealed) ([]record.Source, error) { trees := make([]any, len(records)) for i, rec := range records { tree := make(map[string]any, len(p.Fields)) for _, f := range p.Fields { - hasCiphertext := false - for _, o := range f.Outputs { - hasCiphertext = hasCiphertext || o == record.Ciphertext - } - if !hasCiphertext { + if !f.HasCiphertext() { continue } outputs, ok := rec[f.Name] + if f.IsTarget() { + if !ok || outputs.EQL == nil { + return nil, fmt.Errorf("%w: row %d, field %q", errNoEQL, i, f.Name) + } + // The EQL value rides as a passthrough byte node, the shape + // the engine stored it in; opening it runs the EQL type's own + // decryption on the ciphertext inside the JSON. + tree[f.Name] = map[string]any{record.EQLKey: vcvalue.Plain{V: append([]byte(nil), outputs.EQL...)}} + continue + } if !ok || outputs.Ciphertext == nil { return nil, fmt.Errorf("%w: row %d, field %q", errNoCiphertext, i, f.Name) } @@ -313,14 +369,10 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, p *record.Plan, r src[f.Key] = f.Value } for _, f := range p.Fields { - if _, ok := src[f.Name]; !ok { + if _, ok := src[f.Name]; !ok && f.HasCiphertext() { // An index-only field has no ciphertext to open and comes - // back as nothing; every sealed field must. - for _, o := range f.Outputs { - if o == record.Ciphertext { - return nil, fmt.Errorf("%w: record %d lacks field %q", ErrInternal, i, f.Name) - } - } + // back as nothing; every sealed or target field must. + return nil, fmt.Errorf("%w: record %d lacks field %q", ErrInternal, i, f.Name) } } sources[i] = src diff --git a/languages/golang/internal/record/record.go b/languages/golang/internal/record/record.go index 63805b3e6..2a0ad708a 100644 --- a/languages/golang/internal/record/record.go +++ b/languages/golang/internal/record/record.go @@ -61,6 +61,12 @@ const ( Ope Output = "ope" ) +// EQLKey is the stored key a target field's EQL value rides under: not an +// output a plan asks for (the field names a Target instead), but the key of +// its one node in a sealed record, fixed by stack-encrypt's +// dynamic::record::EQL_KEY. +const EQLKey = "eql" + // IsTerm reports whether the output is an index term. func (o Output) IsTerm() bool { return o != Ciphertext && o != "" } @@ -74,8 +80,31 @@ type Field struct { Identity string // Kind is the declared type, or Untyped. Kind Kind - // Outputs are the field's outputs: Ciphertext and/or terms, at least one. + // Outputs are the field's outputs: Ciphertext and/or terms, at least one + // — unless the field names a Target, which has none of its own. Outputs []Output + // Target is the EQL type the field seals into (encrypt_into), such as + // TextEq, or "". A field has outputs or a target, never both: the EQL + // type's own plan decides what is sealed and which terms sit beside it, + // and the engine returns the finished value under EQLKey. + Target string +} + +// IsTarget reports whether the field names an EQL type. +func (f Field) IsTarget() bool { return f.Target != "" } + +// HasCiphertext reports whether the field's stored node is opened on +// decrypt: a ciphertext output, or an EQL value. +func (f Field) HasCiphertext() bool { + if f.IsTarget() { + return true + } + for _, o := range f.Outputs { + if o == Ciphertext { + return true + } + } + return false } // Plan is a declaration as the engine reads it: one context, any extension, @@ -196,6 +225,22 @@ func (p *Plan) Validate() error { if !f.Kind.Known() { return fmt.Errorf("record: field %q: unknown kind %q", f.Name, f.Kind) } + if f.IsTarget() { + if len(f.Outputs) != 0 { + return fmt.Errorf("record: field %q names the EQL type %s and outputs; a field has one or the other", f.Name, f.Target) + } + // An EQL value is stored under a table and a column: the + // context is the table, the identity the column. The engine + // refuses anything else when the plan is built; saying it here + // names the field before the guest is asked. + if len(p.Context) != 1 { + return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: the context %q has %d segments, not one", f.Name, f.Target, strings.Join(p.Context, "/"), len(p.Context)) + } + if len(p.Extension) != 0 { + return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: an extended context has no column", f.Name, f.Target) + } + continue + } if len(f.Outputs) == 0 { return fmt.Errorf("record: field %q has no output", f.Name) } @@ -242,17 +287,20 @@ func (p *Plan) Field(name string) *Field { } // Wire renders the plan as the guest parses it: per field, its context -// (the label, extended), its outputs and its type. Validate first. +// (the label, extended), its outputs or its target, and its type. Validate +// first. func (p *Plan) Wire() vcvalue.Object { out := make(vcvalue.Object, 0, len(p.Fields)) for _, f := range p.Fields { - outputs := make([]any, len(f.Outputs)) - for i, o := range f.Outputs { - outputs[i] = string(o) - } - spec := vcvalue.Object{ - {Key: "context", Value: p.FieldContext(f)}, - {Key: "outputs", Value: outputs}, + spec := vcvalue.Object{{Key: "context", Value: p.FieldContext(f)}} + if f.IsTarget() { + spec = append(spec, vcvalue.Field{Key: "target", Value: f.Target}) + } else { + outputs := make([]any, len(f.Outputs)) + for i, o := range f.Outputs { + outputs[i] = string(o) + } + spec = append(spec, vcvalue.Field{Key: "outputs", Value: outputs}) } if f.Kind != Untyped { spec = append(spec, vcvalue.Field{Key: "type", Value: string(f.Kind)}) @@ -301,10 +349,13 @@ type Source = map[string]any // Outputs is what the engine produced for one sealed field. type Outputs struct { - // Ciphertext is the frozen leaf bytes, or nil for an index-only field. + // Ciphertext is the frozen leaf bytes, or nil for an index-only field + // or a target field. Ciphertext []byte // Terms are the index terms by output, each its frozen bytes. Terms map[Output][]byte + // EQL is the EQL value's JSON bytes for a field that names a Target. + EQL []byte } // Sealed is one record as stored: each sealed field's outputs by name. diff --git a/languages/golang/internal/record/record_test.go b/languages/golang/internal/record/record_test.go index c2e71b798..e12441418 100644 --- a/languages/golang/internal/record/record_test.go +++ b/languages/golang/internal/record/record_test.go @@ -75,6 +75,54 @@ func TestExtensionNestsToTheLeftAndIdentityReplacesTheName(t *testing.T) { } } +func TestTargetFieldWiresItsTypeInsteadOfOutputs(t *testing.T) { + p := &Plan{Context: []string{"users"}, Fields: []Field{ + {Name: "email", Kind: String, Target: "TextEq"}, + {Name: "notes", Kind: String, Outputs: []Output{Ciphertext}}, + }} + if err := p.Validate(); err != nil { + t.Fatal(err) + } + if !p.Fields[0].IsTarget() || !p.Fields[0].HasCiphertext() || p.Fields[1].IsTarget() { + t.Fatal("a target field is one, and is opened; a sealed field is not a target") + } + want := vcvalue.Object{ + {Key: "email", Value: vcvalue.Object{ + {Key: "context", Value: []any{"users", "email"}}, + {Key: "target", Value: "TextEq"}, + {Key: "type", Value: "string"}, + }}, + {Key: "notes", Value: vcvalue.Object{ + {Key: "context", Value: []any{"users", "notes"}}, + {Key: "outputs", Value: []any{"c"}}, + {Key: "type", Value: "string"}, + }}, + } + if wire := p.Wire(); !reflect.DeepEqual(wire, want) { + t.Fatalf("Wire = %#v", wire) + } + both := &Plan{Context: []string{"users"}, Fields: []Field{{Name: "email", Target: "TextEq", Outputs: []Output{Ciphertext}}}} + if err := both.Validate(); err == nil { + t.Fatal("a field with a target and outputs was accepted") + } + // An EQL column is a table and a column: a two-segment context, or an + // extension, leaves the target field no column, and the refusal names + // the field — before the guest is asked. + deep := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}} + if err := deep.Validate(); err == nil || !strings.Contains(err.Error(), `field "email"`) || !strings.Contains(err.Error(), `"app/users" has 2 segments`) { + t.Fatalf("a target under app/users: %v", err) + } + extended := &Plan{Context: []string{"users"}, Extension: []any{uint64(7)}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}} + if err := extended.Validate(); err == nil || !strings.Contains(err.Error(), "extended context has no column") { + t.Fatalf("an extended target: %v", err) + } + // The same context seals a plain field as before. + plain := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Outputs: []Output{Ciphertext}}}} + if err := plain.Validate(); err != nil { + t.Fatal(err) + } +} + func TestValidateRefusals(t *testing.T) { one := func(f Field) *Plan { return &Plan{Context: []string{"users"}, Fields: []Field{f}} } cases := map[string]*Plan{ diff --git a/languages/golang/stashgen/engine.go b/languages/golang/stashgen/engine.go index ab104ef7d..7cafa2197 100644 --- a/languages/golang/stashgen/engine.go +++ b/languages/golang/stashgen/engine.go @@ -104,7 +104,7 @@ func (e *guestEngine) Check(ctx context.Context, d Declaration) error { field := d.field(f.Name) return &FieldError{Type: d.Type, Field: field.GoName, Reason: fmt.Sprintf( "the engine refuses the declaration: %s over a %s value under %q (%v)", - describeOutputs(f.Outputs), kindWord(f.Kind), plan.Descriptor(f), err)} + describeField(f), kindWord(f.Kind), plan.Descriptor(f), err)} } } if err := e.checker.Check(ctx, plan); err != nil { @@ -135,10 +135,38 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) { rf := record.Field{Name: f.Name, Identity: f.Identity, Kind: wireKind(f.GoType)} switch f.Verb { case VerbEncryptInto: - // The reader refused anything the engine does not produce; a - // producible type reaches the engine's check in the next build, - // which lowers it. Until then no type is producible. - return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot seal into the EQL type %s in this build", f.EQLType)} + if len(eqlTypes) == 0 { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: "EQL types are not available yet; the engine produces none in this build"} + } + var eqlType *EQLType + for i := range eqlTypes { + if eqlTypes[i].Name == f.EQLType { + eqlType = &eqlTypes[i] + } + } + if eqlType == nil { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot produce the EQL type %s yet: no EQL type has that name", f.EQLType)} + } + if !eqlType.Producible { + // The engine's own reason, from se_targets: the generator + // holds no copy of why a type waits. + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot produce the EQL type %s yet: %s", f.EQLType, eqlType.Reason)} + } + // The engine checks the kind too (se_plan_check refuses a + // "type" other than the EQL type's plaintext); saying it here + // names the two kinds. + if eqlType.Plaintext != KindOther && eqlType.Plaintext != f.GoType.Kind { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s seals a %s, and %s is %s", f.EQLType, eqlType.Plaintext, f.GoType, f.GoType.Kind)} + } + // An EQL value is stored under a table and a column: the struct's + // context is the table and the field's name the column, so a + // context of two or more segments leaves the field no column. + // The engine refuses it too (se_plan_check); naming it here + // names the field. + if len(segments) != 1 { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(segments))} + } + rf.Target = f.EQLType case VerbEncrypt, VerbEncryptIndex: rf.Outputs = append(rf.Outputs, record.Ciphertext) } @@ -223,6 +251,17 @@ func kindWord(k record.Kind) string { return string(k) } +// describeField names what the engine was asked for a field: the EQL type +// it names, or its outputs. For an EQL type the engine refuses, the refusal +// is the engine's (an unknown or unproducible type, a kind other than its +// plaintext, an extended context); the name is what a reader needs. +func describeField(f record.Field) string { + if f.IsTarget() { + return "the EQL type " + f.Target + } + return describeOutputs(f.Outputs) +} + func describeOutputs(outputs []record.Output) string { words := make([]string, 0, len(outputs)) for _, o := range outputs { diff --git a/languages/golang/stashgen/read.go b/languages/golang/stashgen/read.go index 5a5827fd4..2b8bda2df 100644 --- a/languages/golang/stashgen/read.go +++ b/languages/golang/stashgen/read.go @@ -134,6 +134,20 @@ type modelField struct { output *output } +// EQLGoName is the Go type name in encrypt/eql of an EQL type the engine +// names: the same name in every language, save the JSON family, whose Go +// names start with JSON (Json is JSON), as Go spells initialisms. The +// generated encrypt/eql package follows the same rule (eql-codegen's go_eql +// renderer), and its Types table records both names, which is what holds +// the two in agreement (a test in cmd/stashgen reads it; this package's own +// tests cannot link encrypt/eql without changing the engine they test). +func EQLGoName(name string) string { + if strings.HasPrefix(name, "Json") { + return "JSON" + strings.TrimPrefix(name, "Json") + } + return name +} + // reader builds a genFile from a loaded package. type reader struct { pkg *packages.Package @@ -677,10 +691,10 @@ func (r *reader) buildFields(c *collected) error { return fieldErr(typeName, cf.goName, "the engine cannot produce the EQL type %s yet: %s", field.EQLType, reason) } f.imports.add(eqlPath, "eql") - g.outputType = "eql." + eqlType.Name - g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + eqlType.Name}} + g.outputType = "eql." + EQLGoName(eqlType.Name) + g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + EQLGoName(eqlType.Name)}} if eqlType.Query != "" { - g.queryType = "eql." + eqlType.Query + g.queryType = "eql." + EQLGoName(eqlType.Query) } default: g.outputType = f.encName + cf.goName diff --git a/mise.toml b/mise.toml index 80ccee9ab..99c81a215 100644 --- a/mise.toml +++ b/mise.toml @@ -214,6 +214,69 @@ cp "$module" ../testdata/stack_encrypt_guest_deterministic.wasm echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm" """ +# The guest build that holds the EQL types (ADR-0007, amended 2026-10-06): +# the same crate with the `eql` feature, which links eql-bindings and installs +# its by-name dispatch as the engine's TargetResolver, so a plan field may +# name `TextEq` and the guest returns the finished EQL value. The Go package +# `encrypt/eql` embeds it and registers it on import; a program that names an +# EQL type links this build, every other program links the one above. Same +# import-surface gate as the real build: the EQL types add no host import. +# Both builds ship until the size difference has been weighed (the plan asks +# for the measurement first); the build logs both sizes. +[tasks."wasm:guest:build:eql"] +description = "Build the stack-encrypt WASI guest with the EQL types (feature eql; wasm32-wasip1, release), assert its host-import surface and report both builds' sizes" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/encrypt/guest +cargo build --target wasm32-wasip1 --release --features eql +module=target/wasm32-wasip1/release/stack_encrypt_guest.wasm +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --deny-prefix wasi_snapshot_preview1:fd_prestat \\ + --require wasi_snapshot_preview1:random_get \\ + --require cipherstash_transport:transport_send \\ + --require cipherstash_transport:token_get +mkdir -p ../eql/wasm +cp "$module" ../eql/wasm/stack_encrypt_guest_eql.wasm +echo "embedded into languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm" +# The measurement the plan asks for before the SDK settles on one build or +# two: the build without EQL types beside this one, when it has been built. +plain=../wasm/stack_encrypt_guest.wasm +if [ -f "$plain" ]; then + echo "size without EQL types: $(wc -c < "$plain") bytes ($plain)" +fi +echo "size with EQL types: $(wc -c < ../eql/wasm/stack_encrypt_guest_eql.wasm) bytes" +""" + +# The deterministic-kms TEST build with the EQL types: what the hermetic Go +# tests of a TextEq field load, as the one above is what the other hermetic +# tests load. Same gate, and the same rule: nothing test-only in an embed, so +# it lands under encrypt/testdata beside the other test build. +[tasks."wasm:guest:build:eql:deterministic"] +description = "Build the deterministic-kms TEST build of the stack-encrypt WASI guest with the EQL types (features eql,deterministic-kms), for the hermetic Go tests of EQL fields" +shell = "bash -c" +run = """ +set -euo pipefail +rustup target add wasm32-wasip1 +root=$(pwd) +cd languages/golang/encrypt/guest +cargo build --target wasm32-wasip1 --release --features eql,deterministic-kms +module=target/wasm32-wasip1/release/stack_encrypt_guest.wasm +python3 "$root/scripts/check-wasm-imports.py" "$module" \\ + --allow-module wasi_snapshot_preview1 \\ + --deny-prefix wasi_snapshot_preview1:path_ \\ + --deny-prefix wasi_snapshot_preview1:sock_ \\ + --deny-prefix wasi_snapshot_preview1:fd_prestat \\ + --require wasi_snapshot_preview1:random_get +cp "$module" ../testdata/stack_encrypt_guest_eql_deterministic.wasm +echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm" +""" + [tasks."wasm:guest:test"] description = "Lint and natively test the stack-encrypt WASI guest (ops/config/status modules run on the host target)" shell = "bash -c" @@ -230,10 +293,16 @@ cd languages/golang/encrypt/guest cargo fmt --check cargo clippy --all-targets -- -D warnings cargo clippy --all-targets --features deterministic-kms -- -D warnings +# The build with the EQL types, and the test build of it, lint and test too: +# the resolver module and the target path exist only there. +cargo clippy --all-targets --features eql -- -D warnings +cargo clippy --all-targets --features eql,deterministic-kms -- -D warnings # The wasm32-only modules (abi, host) only compile for the target; lint # them there so a broken export surface can't hide behind native-only CI. cargo clippy --target wasm32-wasip1 -- -D warnings cargo clippy --target wasm32-wasip1 --features deterministic-kms -- -D warnings +cargo clippy --target wasm32-wasip1 --features eql -- -D warnings +cargo clippy --target wasm32-wasip1 --features eql,deterministic-kms -- -D warnings # Intra-doc links, on the target the crate is written for (the wasm32-only # modules are part of the crate docs). rustdoc only warns on a broken link # and exits 0, so without -D warnings a stale link ships silently. @@ -241,6 +310,7 @@ RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --target wasm32-wasip1 # nextest, as everywhere else; this crate is a detached workspace, so it # runs from here rather than a root `-p`. nextest is in mise.test.toml. mise x --env test -- cargo nextest run +mise x --env test -- cargo nextest run --features eql """ [tasks."wasm:auth-guest:build"] diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 32573ce95..18f8ac534 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1202,8 +1202,11 @@ dependencies = [ "serde", "serde_json", "stack-encrypt", + "thiserror 2.0.20", "ts-rs", + "vitaminc-aead-value", "vitaminc-prf", + "zeroize", ] [[package]] @@ -1234,6 +1237,7 @@ version = "0.1.0" dependencies = [ "base64", "eql-bindings", + "eql-domains", "postgres", "serde", "serde_json", @@ -1241,6 +1245,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", + "vitaminc-aead-value", ] [[package]] @@ -5072,6 +5077,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-aead-value" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +dependencies = [ + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "zeroize", +] + [[package]] name = "vitaminc-context" version = "0.5.1" diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 2bfe64ee6..242167c93 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **EQL types as plan field targets, by name** (`stack-encrypt` feature). + `eql_bindings::encryption::targets` holds a catalog-generated table + (`targets()`) of every EQL type a Stack Encrypt data plan may name as a + field target — name, family, suffix, plaintext `ValueKind`, SQL domain, + indexes, query twin, and whether the engine can produce it today with the + reason when not — serializable as the guest's `se_targets` export, and + `encrypt` / `decrypt` / `query` entry points that dispatch on the name and + run the type's own `EncryptFrom` / `DecryptInto` plan, resolving to the EQL + value's JSON bytes through the engine's `Pending`. `Identifier::from_label` + reads a two-segment plan label as the column identifier. `TextEq` is the + one producible type; every other name is refused with its reason. + - **Scalar query-operand bindings.** Every term-bearing scalar domain now has a generated query twin — `IntegerEqQuery`, `IntegerOrdOpeQuery`, `TextSearchQuery`, … — the **enveloped term-only** operand `{v, i, }` diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index 4895d3e23..602ff7851 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -49,12 +49,30 @@ stack-encrypt = { path = "../../../stack-encrypt", version = "0.2.0", optional = # `PrfContext`), and two vitaminc lines in one graph do not interoperate, so # the requirement is stack-encrypt's. vitaminc-prf = { version = "0.5.0", optional = true } +# `FfiValue` / `ValueKind`: the runtime plaintext a plan field target takes +# and the kind names a plan's `"type"` key spells (`encryption::targets`). +# Named from vitaminc directly, like `PrfValue` above, so the feature needs +# nothing from stack-encrypt's own `dynamic` feature; the same line +# stack-encrypt and the Go guest build against. +vitaminc-aead-value = { version = "0.5.1", optional = true } +# The plaintext a target reads out of its `Protected` runtime value is held +# in `Zeroizing` until it is sealed, so the copy is wiped like the original. +zeroize = { version = "1", optional = true } +thiserror = { version = "2", optional = true } base64 = { version = "0.22", optional = true } hex = { version = "0.4", optional = true } [features] default = [] -stack-encrypt = ["dep:stack-encrypt", "dep:vitaminc-prf", "dep:base64", "dep:hex"] +stack-encrypt = [ + "dep:stack-encrypt", + "dep:vitaminc-prf", + "dep:vitaminc-aead-value", + "dep:zeroize", + "dep:thiserror", + "dep:base64", + "dep:hex", +] [package.metadata.docs.rs] features = ["stack-encrypt"] diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 97e278a73..823db57d4 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -172,6 +172,47 @@ column returns zero rows with no error. The separation will come from the EQL version itself (v4), not from a marker inside v3. Until then, one profile per column, and record which. +### EQL types as plan field targets + +A binding has no Rust type to name: its plan arrives as data, and a field of +that plan names its EQL type as a string (`"TextEq"`). The +`eql_bindings::encryption::targets` module is where the string meets the type. +`targets()` is the catalog-generated table of every EQL type a plan may name — +its name across languages, family and suffix, the plaintext kind it takes (a +vitaminc `ValueKind` name, the same names a plan field's `"type"` key uses), the +indexes it carries (`eq` / `match` / `ore` / `ope` / `json`), its query twin, +and whether the engine can produce it today, with the reason when not. A guest +serializes it for its `se_targets` export; the module docs are the wire format. +`encrypt`, `decrypt` and `query` dispatch on the name and run the type's own +Rust plan — the same `EncryptFrom` the typed `encrypt_as::` runs, so +the two paths produce the same identifier and equality term and open each +other's values. An unproducible name is refused with the table's reason; an +unknown one with "no such EQL type". + +```rust,ignore +use eql_bindings::encryption::targets; +use stack_encrypt::Label; +use vitaminc_aead_value::FfiValue; + +let column = Label::new(["users", "email"])?; // the field's context: table/column +let stored: Vec = targets::encrypt("TextEq", &keyset, &column, FfiValue::String("alice@example.com".into()))?.await?; +let probe: Vec = targets::query("TextEq", &keyset, &column, FfiValue::String("alice@example.com".into()))?.await?; +let opened: FfiValue = targets::decrypt("TextEq", &cipher, &column, &stored)?.await?; +``` + +The table and the dispatch are generated into `src/v3/targets.rs` beside +`inventory.rs` (`mise run types:generate`), gated to the `stack-encrypt` +feature, and drift-gated by the same parity tests. The same generator writes +the Go package `languages/golang/encrypt/eql` from the same rows +(`eql-codegen go-eql`, also under `types:generate` / `types:check`). The +engine's `TargetResolver` (stack-encrypt's `dynamic` module) is implemented +over this module by the Go guest's `eql` build, not here: a resolver in this +crate would need stack-encrypt API newer than the crates.io release the +published crate names, and the guest already depends on both. A type is producible exactly +when its generated struct carries the `stack-encrypt` derives +(`ENCRYPTION_DOMAINS` in `eql-codegen`), because the dispatch runs the derived +plan; `TextEq` is the only one today. + ### Developing the `stack-encrypt` feature Stack Encrypt lives in this repository (`packages/stack-encrypt`), and the diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index df9a8b8d8..fccab8307 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -41,6 +41,11 @@ //! builder with `StackCipher::new().await?`, using your configured CipherStash //! credentials. The encryption and decryption calls are identical. //! +//! A binding that holds no Rust type to name reaches the same plans by the +//! type's *name* through [`targets`]: the catalog-generated table of EQL +//! types a data plan may name as a field target, and `encrypt` / `decrypt` / +//! `query` dispatching on that name. +//! //! Use the same table and column identifier for writes and queries. Passing //! `column.into()` on decryption also checks that the stored identifier matches //! the expected destination before retrieving keys. `Default::default()` instead @@ -72,6 +77,8 @@ //! require::(); //! ``` +pub mod targets; + use base64::{engine::general_purpose::STANDARD, Engine as _}; use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::transcode::{Reader, Transcode, Visitor}; diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs new file mode 100644 index 000000000..06dc034f1 --- /dev/null +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -0,0 +1,616 @@ +//! EQL types as plan field targets, by name. +//! +//! A Rust caller names an EQL type as a type: `encrypt_as::` runs the +//! plan `TextEq`'s own `EncryptFrom` describes. A binding has no type to +//! name — its plan arrives as data, and a field of that plan names its target +//! as a string, `"TextEq"`. This module is where that string meets the type: +//! a catalog-generated table of every EQL type a plan may name +//! ([`targets`], [`Target`]) and three entry points ([`encrypt`], +//! [`decrypt`], [`query`]) that dispatch on the name and run *the same plan* +//! the typed call runs. Nothing here derives a term or seals a byte itself +//! (ADR-0007: one engine, entered through a plan); the dispatch is generated +//! from `eql-domains::CATALOG` by `eql-codegen` into +//! [`crate::v3::targets`], beside the inventory, so a type cannot be in the +//! catalog and missing from the table. +//! +//! The guest build that holds the EQL types links this module; the build +//! without them has no `eql-bindings` and refuses a target name before +//! reaching here. +//! +//! # Wire format: the target table +//! +//! [`targets`] is what a guest serializes for its `se_targets` export, so a +//! generator (`stashgen`) can ask the engine it embeds which EQL types it +//! holds instead of carrying a copy of the rules. The JSON of one entry, with +//! every field always present: +//! +//! ```json +//! { +//! "name": "TextEq", +//! "family": "text", +//! "suffix": "Eq", +//! "plaintext": "string", +//! "sql_domain": "public.eql_v3_text_eq", +//! "indexes": ["eq"], +//! "query": "TextEqQuery", +//! "query_sql_domain": "eql_v3.query_text_eq", +//! "producible": true, +//! "reason": null +//! } +//! ``` +//! +//! | field | meaning | +//! |---|---| +//! | `name` | The type's name, one across Rust, TypeScript and Go: the catalog struct identifier. The data plan's target form names this. (Go spells the `Json` family's types `JSON`; that rename is the Go generator's.) | +//! | `family` | The catalog family, lower case: `text`, `integer`, `json`, … | +//! | `suffix` | What a query can do, as the plan's table of suffixes spells it: `""` (stored and read only), `Eq`, `Ord`, `OrdOpe`, `OrdOre`, `Match`, `Search`, `SearchOre`; `Search` for the SteVec document. | +//! | `plaintext` | The vitaminc [`ValueKind`] name the type is produced from — the same names a plan field's `"type"` key uses — or `null` while the family's plaintext encoding for the stack-encrypt producer profile is unspecified. | +//! | `sql_domain` | The schema-qualified PostgreSQL domain the stored value inhabits. | +//! | `indexes` | The indexes the type carries, by the engine's `IndexSpec::key()` names: `eq`, `match`, `ore`, `ope`, and `json` for the SteVec document. A query may ask a field typed with this target for exactly these. | +//! | `query` | The query twin's type name (`TextEqQuery`, `SteVecQuery`), or `null` for a storage-only type, which answers no query. | +//! | `query_sql_domain` | The query twin's PostgreSQL domain, `null` likewise. | +//! | `producible` | Whether the engine can produce this type today. [`encrypt`] and [`query`] refuse a type that is not, and a generator should too. | +//! | `reason` | Why not, when `producible` is `false`; `null` when it is. | +//! +//! The table has one row per stored domain in catalog order; query twins are +//! not rows (each row names its own). Adding a field is a wire change for +//! every reader of `se_targets`; renaming or removing one is a breaking one. +//! +//! # What crosses: bytes +//! +//! [`encrypt`] and [`query`] resolve to the EQL value as **JSON bytes** — the +//! bytes PostgreSQL stores or compares — and [`decrypt`] takes them back. +//! The [`Pending`] they return is the engine's own request carrier: the guest +//! zips it with the other fields' pendings so one plan still makes one +//! ZeroKMS request. A query derives no key, so its pending settles without +//! I/O, but it is a `Pending` all the same, for one shape at the call site. +//! +//! # The field's context +//! +//! A plan field's context is a [`Label`]; an EQL value stores an +//! [`Identifier`], table and column. The two are the same context when the +//! label has two segments ([`Identifier::from_label`]): `users/email` is +//! `{"t": "users", "c": "email"}`, sealed under the same AAD, bound to the +//! same ZeroKMS descriptor and deriving the same equality term as the typed +//! `encrypt_as::` call with `Identifier::for_column("users", +//! "email")`. A label of any other length is refused: there is no column to +//! store it in. + +use std::fmt; + +use serde::{de::DeserializeOwned, Serialize}; +use stack_encrypt::kms::MaybeSend; +use stack_encrypt::target::ExpectedContext; +use stack_encrypt::{ + DecryptInto, EncryptFrom, Error, KeysetCipher, Label, NonEmpty, Pending, StackCipher, +}; +use vitaminc_aead_value::{FfiValue, ValueKind}; + +use crate::v3::targets::{decrypt_named, encrypt_named, query_named, TARGETS}; +use crate::Identifier; + +/// One EQL type a plan may name as a field target: a row of [`targets`]. +/// +/// The fields are the wire format of the `se_targets` export; the module +/// documentation is their reference. Every value is a `&'static str` or a +/// slice of them because the whole table is a `const` generated from the +/// catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] +pub struct Target { + /// The type's name across languages: `TextEq`. + pub name: &'static str, + /// The catalog family: `text`. + pub family: &'static str, + /// The query-capability suffix: `Eq`; empty for a storage-only type. + pub suffix: &'static str, + /// The [`ValueKind`] name of the plaintext this type is produced from, + /// or `None` while the family's encoding is unspecified. See + /// [`plaintext_kind`](Self::plaintext_kind) for the parsed kind. + pub plaintext: Option<&'static str>, + /// The stored value's PostgreSQL domain: `public.eql_v3_text_eq`. + pub sql_domain: &'static str, + /// The indexes the type carries, by `IndexSpec::key()` name. + pub indexes: &'static [&'static str], + /// The query twin's type name, or `None` for a storage-only type. + pub query: Option<&'static str>, + /// The query twin's PostgreSQL domain, or `None` likewise. + pub query_sql_domain: Option<&'static str>, + /// Whether the engine produces this type today. + pub producible: bool, + /// Why it does not, when it does not. + pub reason: Option<&'static str>, +} + +impl Target { + /// The plaintext kind, parsed. `None` when the table records none; the + /// table is generated from names vitaminc freezes, so a recorded name + /// always parses, and a `None` here means the same as a `None` in + /// [`plaintext`](Self::plaintext). + pub fn plaintext_kind(&self) -> Option { + self.plaintext.and_then(|name| name.parse().ok()) + } +} + +/// Every EQL type a plan may name as a target, in catalog order — the +/// `se_targets` export, as data. See the module documentation for the wire +/// format. +pub fn targets() -> &'static [Target] { + TARGETS +} + +/// The target of this name, or `None` when no EQL type has it. The name is +/// matched exactly: `"TextEq"`, not `"texteq"` or `"text_eq"`. +pub fn target(name: &str) -> Option<&'static Target> { + TARGETS.iter().find(|target| target.name == name) +} + +/// Why a name could not be resolved to a plan, or a value could not be +/// handed to one. +/// +/// Every variant is decided before any key is minted or retrieved: these +/// are statements about the name, the context or the value. The encryption +/// itself failing arrives through the returned [`Pending`] as a +/// [`stack_encrypt::Error`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TargetError { + /// No EQL type has this name. + #[error("no such EQL type: {name}")] + Unknown { + /// The name as it was given. + name: String, + }, + /// The type exists, and the engine cannot produce it yet; [`Target::reason`] + /// says why. + #[error("the engine cannot produce {name} yet: {reason}")] + Unproducible { + /// The type's name. + name: &'static str, + /// The table's reason. + reason: &'static str, + }, + /// The type is produced, and answers no query: a storage-only type has + /// no query twin, so there is no operand to derive. + #[error("{name} answers no query: it is a storage-only type")] + NoQuery { + /// The type's name. + name: &'static str, + }, + /// The field's context is not an EQL column: an [`Identifier`] is a + /// two-segment label, table then column, and this label is not one. + #[error("{label:?} is not an EQL column identifier: expected two segments, table and column")] + Context { + /// The label, rendered. + label: String, + }, + /// The value is not of the type's plaintext kind. Nothing is converted: + /// the stored bytes must be the declared kind's, and a conversion here + /// would make them something else. + #[error( + "{target} is produced from a {expected} plaintext, not {}", + found.map_or("a value with no kind", ValueKind::name) + )] + Plaintext { + /// The type the value was handed to. + target: &'static str, + /// The kind it takes. + expected: ValueKind, + /// The kind it was given, or `None` for a null, undefined or + /// passthrough value, which no kind holds. + found: Option, + }, + /// The stored bytes do not parse as the type: not JSON, not this + /// domain's shape, or another EQL version. + #[error("stored value is not a {target}: {source}")] + Stored { + /// The type the bytes were read as. + target: &'static str, + /// What the parser refused. + #[source] + source: serde_json::Error, + }, +} + +/// Which cipher [`decrypt`] opens through: the client, which opens a value +/// sealed under any of its keysets, or one keyset, which refuses a value +/// sealed under another ([`stack_encrypt::Error::ForeignKeyset`]) before any +/// key is retrieved. The runtime form of the engine's scope, for a caller +/// who chooses at runtime; a typed caller chooses by naming the cipher. +/// Both references convert into it, so a call site passes either. +pub enum Opener<'a, K> { + /// Values from any keyset the client holds. + Client(&'a StackCipher), + /// Values from this keyset only. + Keyset(&'a KeysetCipher<'a, K>), +} + +// By hand so `K: Debug` is not demanded: neither cipher demands it of its +// own `Debug`. +impl fmt::Debug for Opener<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Opener::Client(cipher) => f.debug_tuple("Client").field(cipher).finish(), + Opener::Keyset(keyset) => f.debug_tuple("Keyset").field(keyset).finish(), + } + } +} + +impl<'a, K> From<&'a StackCipher> for Opener<'a, K> { + fn from(cipher: &'a StackCipher) -> Self { + Opener::Client(cipher) + } +} + +impl<'a, K> From<&'a KeysetCipher<'a, K>> for Opener<'a, K> { + fn from(keyset: &'a KeysetCipher<'a, K>) -> Self { + Opener::Keyset(keyset) + } +} + +impl Identifier { + /// The column a plan field's context names: a two-segment label, table + /// then column, as the identifier the value stores and is sealed under. + /// The same context as the label itself — see the module documentation. + /// + /// # Errors + /// + /// [`TargetError::Context`] for a label of any other length. + pub fn from_label(label: &Label) -> Result, TargetError> { + let context = || TargetError::Context { + label: label.to_string(), + }; + let mut segments = label.segments(); + let (Some(table), Some(column), None) = (segments.next(), segments.next(), segments.next()) + else { + return Err(context()); + }; + // A label segment is plain, so never empty; the `Err` arm is the + // type's, not a case this function can reach. + Identifier::for_column(table, column).map_err(|_| context()) + } +} + +/// Run the named type's own encryption plan for one plan field. +/// +/// `context` is the field's label, which must name a column (see +/// [`Identifier::from_label`]); `plaintext` must be of the type's plaintext +/// kind ([`Target::plaintext`]). The result settles to the EQL value's JSON +/// bytes, and merges with other pendings into one ZeroKMS request. +/// +/// # Errors +/// +/// [`TargetError::Unknown`] for a name no EQL type has, +/// [`TargetError::Unproducible`] for one the engine cannot produce yet, +/// [`TargetError::Context`] for a label that is not a column, and +/// [`TargetError::Plaintext`] for a value of another kind. All decided before +/// any key is minted; the encryption itself fails through the pending. +pub fn encrypt<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + context: &Label, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + producible(name)?; + let column = Identifier::from_label(context)?; + encrypt_named(name, keyset, column, plaintext) +} + +/// Open a stored value of the named type back to its plaintext, checking +/// that it was stored under `context`'s column. +/// +/// # Errors +/// +/// [`TargetError::Unknown`], [`TargetError::Unproducible`] and +/// [`TargetError::Context`] as for [`encrypt`], and [`TargetError::Stored`] +/// for bytes that are not this type. A stored identifier that differs from +/// `context`, a value sealed under a keyset the opener does not hold, and a +/// failed authentication all fail through the pending. +pub fn decrypt<'a, K: 'static>( + name: &str, + opener: impl Into>, + context: &Label, + stored: &[u8], +) -> Result, TargetError> { + producible(name)?; + let column = Identifier::from_label(context)?; + decrypt_named(name, opener.into(), column, stored) +} + +/// Run the named type's query twin for one plaintext: the operand that +/// matches stored values of the type under `context`'s column, as JSON +/// bytes. A query derives no data key, so the pending settles without I/O. +/// +/// # Errors +/// +/// As [`encrypt`]. +pub fn query<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + context: &Label, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + producible(name)?; + if let Some(error) = no_query(target(name)) { + return Err(error); + } + let column = Identifier::from_label(context)?; + query_named(name, keyset, column, plaintext) +} + +/// Refuse a name the dispatch has no arm for, as the table explains it. +fn producible(name: &str) -> Result<(), TargetError> { + match target(name) { + Some(target) if target.producible => Ok(()), + _ => Err(refuse(name)), + } +} + +/// The error for a name without a dispatch arm: unproducible when the table +/// has it, unknown otherwise. Called by the generated dispatch's fall-through. +/// The error for a query on a name the query dispatch has no arm for: a +/// producible type with no query twin answers no query; otherwise what +/// [`refuse`] says. Called by the generated query dispatch's fall-through. +pub(crate) fn refuse_query(name: &str) -> TargetError { + no_query(target(name)).unwrap_or_else(|| refuse(name)) +} + +/// `NoQuery` for a producible type without a query twin, else `None`: the +/// one case the query dispatch refuses that the others do not. +fn no_query(target: Option<&'static Target>) -> Option { + match target { + Some(target) if target.producible && target.query.is_none() => { + Some(TargetError::NoQuery { name: target.name }) + } + _ => None, + } +} + +pub(crate) fn refuse(name: &str) -> TargetError { + match target(name) { + Some(target) => TargetError::Unproducible { + name: target.name, + // A producible type reaching here is a generator bug: the table + // says yes and the dispatch has no arm. Say so rather than panic. + reason: target + .reason + .unwrap_or("the generated dispatch has no arm for this type"), + }, + None => TargetError::Unknown { + name: name.to_owned(), + }, + } +} + +mod sealed { + pub trait Sealed {} + impl Sealed for String {} +} + +/// A Rust plaintext an EQL type is produced from, read out of and written +/// back into the runtime value. Sealed: the implementations are exactly the +/// plaintext types the catalog's producible families name, and the generated +/// dispatch picks one per type. `Zeroize`, because [`from_value`](Self::from_value) +/// copies the plaintext out of the runtime value's `Protected` buffer and +/// the copy is wiped when it is dropped ([`run_target`] holds it in +/// [`zeroize::Zeroizing`]), as the original is. +pub trait Plaintext: sealed::Sealed + Sized + MaybeSend + zeroize::Zeroize + 'static { + /// The kind of value this plaintext is. + const KIND: ValueKind; + /// Read the value as this plaintext, refusing any other kind. + fn from_value(target: &'static str, value: FfiValue) -> Result; + /// The opened plaintext, as the runtime value. + fn into_value(self) -> FfiValue; +} + +impl Plaintext for String { + const KIND: ValueKind = ValueKind::String; + + fn from_value(target: &'static str, value: FfiValue) -> Result { + let refused = |found| TargetError::Plaintext { + target, + expected: Self::KIND, + found, + }; + match value { + // The bytes are UTF-8 by `Utf8String`'s construction invariant; + // checked rather than assumed because this is boundary code. One + // that fails the check is a string in name only, so it is + // reported as the bytes it is. + FfiValue::String(text) => std::str::from_utf8(text.risky_ref()) + .map(str::to_owned) + .map_err(|_| refused(Some(ValueKind::Bytes))), + other => Err(refused(other.kind())), + } + } + + fn into_value(self) -> FfiValue { + FfiValue::String(self.into()) + } +} + +/// Run a target's own plan over one runtime value and resolve to the EQL +/// value's JSON bytes. The generated dispatch calls this with the type and +/// its plaintext; it is the one place the typed `encrypt_as` is reached from +/// a name. +pub(crate) fn run_target<'a, T, S, K>( + name: &'static str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> +where + T: EncryptFrom> + Serialize, + S: Plaintext, + K: 'static, +{ + // The copy lives only until `encrypt_as` has cloned what it seals, and + // is wiped when this returns: the runtime value kept its bytes in + // `Protected`, and the copy is held to the same rule. + let plaintext = zeroize::Zeroizing::new(S::from_value(name, plaintext)?); + Ok(keyset + .encrypt_as::(&plaintext, column) + .try_map(|value| serde_json::to_vec(&value).map_err(|error| Error::Other(Box::new(error))))) +} + +/// Parse a stored EQL value as the target and describe opening it through +/// the target's own `DecryptInto`, under the column the caller expects. +pub(crate) fn open_target<'a, T, S, K>( + name: &'static str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], +) -> Result, TargetError> +where + T: DeserializeOwned + DecryptInto> + 'static, + S: Plaintext, + K: 'static, +{ + let value: T = serde_json::from_slice(stored).map_err(|source| TargetError::Stored { + target: name, + source, + })?; + // `decrypt_as`, not `run_decryption`: this crate compiles against the + // crates.io stack-encrypt its manifest names (0.2.0), which has the + // typed call and not the by-value runner. Same plan, same pending. + let expected: ExpectedContext = column.into(); + Ok(match opener { + Opener::Client(cipher) => cipher.decrypt_as::(value, expected), + Opener::Keyset(keyset) => keyset.decrypt_as::(value, expected), + } + .map(S::into_value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn label(segments: &[&str]) -> Label { + Label::new(segments).unwrap() + } + + #[test] + fn a_two_segment_label_is_the_column_identifier() { + let column = Identifier::from_label(&label(&["users", "email"])).unwrap(); + assert_eq!(column.clone().into_inner().t, "users"); + assert_eq!(column.into_inner().c, "email"); + } + + #[test] + fn a_label_of_any_other_length_is_not_a_column() { + for segments in [&["users"][..], &["tenant", "users", "email"][..]] { + let error = Identifier::from_label(&label(segments)).unwrap_err(); + assert!( + matches!(&error, TargetError::Context { label: l } if l == &segments.join("/")), + "{segments:?}: {error}" + ); + } + } + + #[test] + fn the_table_is_catalog_ordered_and_names_each_type_once() { + let names: Vec<&str> = targets().iter().map(|t| t.name).collect(); + let mut unique = names.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!( + unique.len(), + names.len(), + "a name resolves one type: {names:?}" + ); + assert_eq!( + target("TextEq").map(|t| t.sql_domain), + Some("public.eql_v3_text_eq") + ); + assert_eq!(target("texteq"), None, "names match exactly"); + for t in targets() { + assert_eq!( + t.producible, + t.reason.is_none(), + "{}: a reason exactly when not producible", + t.name + ); + assert_eq!( + t.plaintext_kind().is_some(), + t.plaintext.is_some(), + "{}: a recorded plaintext name is a vitaminc kind", + t.name + ); + } + } + + #[test] + fn a_producible_type_without_a_query_twin_answers_no_query() { + // No such type in the catalog today (`Text` is not producible), so + // the rule is pinned on a synthetic row; `refuse_query` on the live + // table falls through to `refuse`. + let storage_only = Target { + name: "Text", + family: "text", + suffix: "", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text", + indexes: &[], + query: None, + query_sql_domain: None, + producible: true, + reason: None, + }; + let leaked: &'static Target = Box::leak(Box::new(storage_only)); + assert!(matches!( + no_query(Some(leaked)), + Some(TargetError::NoQuery { name: "Text" }) + )); + assert_eq!( + no_query(Some(leaked)).unwrap().to_string(), + "Text answers no query: it is a storage-only type" + ); + assert!( + no_query(target("TextEq")).is_none(), + "TextEq answers a query" + ); + assert!( + no_query(target("Text")).is_none(), + "an unproducible type is refused as that" + ); + assert!(matches!( + refuse_query("Text"), + TargetError::Unproducible { .. } + )); + assert!(matches!(refuse_query("Nope"), TargetError::Unknown { .. })); + } + + #[test] + fn refusals_name_the_type_and_its_reason() { + assert!(matches!(refuse("Nope"), TargetError::Unknown { name } if name == "Nope")); + assert!(matches!( + refuse("TextOrdOre"), + TargetError::Unproducible { name: "TextOrdOre", reason } if reason.contains("CLLW") + )); + assert!(producible("TextEq").is_ok()); + } + + #[test] + fn a_string_plaintext_is_read_exactly_and_nothing_else_is_converted() { + let text = String::from_value("TextEq", FfiValue::String("café".into())).unwrap(); + assert_eq!(text, "café"); + assert!(matches!( + String::from_value("TextEq", FfiValue::UInt64(34)), + Err(TargetError::Plaintext { + target: "TextEq", + expected: ValueKind::String, + found: Some(ValueKind::UInt64) + }) + )); + let error = String::from_value("TextEq", FfiValue::Null).unwrap_err(); + assert!(matches!(error, TargetError::Plaintext { found: None, .. })); + assert_eq!( + error.to_string(), + "TextEq is produced from a string plaintext, not a value with no kind" + ); + assert!(matches!( + String::from("x").into_value(), + FfiValue::String(s) if s.risky_ref() == b"x" + )); + } +} diff --git a/packages/eql/crates/eql-bindings/src/lib.rs b/packages/eql/crates/eql-bindings/src/lib.rs index b88f65b61..1a0f3458f 100644 --- a/packages/eql/crates/eql-bindings/src/lib.rs +++ b/packages/eql/crates/eql-bindings/src/lib.rs @@ -32,6 +32,10 @@ feature = "stack-encrypt", doc = "See the [complete encryption example](encryption#example), including cipher setup, table/column identifiers, and decryption." )] +#![cfg_attr( + feature = "stack-encrypt", + doc = "A binding that names an EQL type as a string reaches the same plans through [`encryption::targets`]: the catalog-generated table of types a data plan may name as a field target, and the by-name `encrypt` / `decrypt` / `query` dispatch." +)] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/packages/eql/crates/eql-bindings/src/v3/mod.rs b/packages/eql/crates/eql-bindings/src/v3/mod.rs index b9c53156f..a23094056 100644 --- a/packages/eql/crates/eql-bindings/src/v3/mod.rs +++ b/packages/eql/crates/eql-bindings/src/v3/mod.rs @@ -142,6 +142,14 @@ pub mod payload; pub mod query_payload; pub mod real; pub mod smallint; +/// Generated: the table of EQL types a Stack Encrypt data plan may name as +/// a field target (`TARGETS`) and the by-name dispatch that runs a +/// producible type's own plan. Only meaningful with the engine, so it is +/// gated here, in the hand-written module list, rather than inside the +/// generated file. The descriptor type, the errors and the public entry +/// points are hand-written in [`crate::encryption::targets`]. +#[cfg(feature = "stack-encrypt")] +pub mod targets; pub mod terms; pub mod text; pub mod timestamp; diff --git a/packages/eql/crates/eql-bindings/src/v3/targets.rs b/packages/eql/crates/eql-bindings/src/v3/targets.rs new file mode 100644 index 000000000..e767f3715 --- /dev/null +++ b/packages/eql/crates/eql-bindings/src/v3/targets.rs @@ -0,0 +1,769 @@ +// @generated by eql-codegen from the eql-domains catalog — do not edit +//! The EQL types a Stack Encrypt data plan may name as a field target — every stored v3 domain type in eql-domains::CATALOG order, as data a guest serializes (`TARGETS`), with the by-name dispatch that runs a producible type's own Rust plan. Generated from the catalog; the descriptor type, the errors and the public entry points stay hand-written in `crate::encryption::targets`, which documents the wire format. +use crate::encryption::targets::{ + open_target, refuse, refuse_query, run_target, Opener, Target, TargetError, +}; +use crate::Identifier; +use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; +use vitaminc_aead_value::FfiValue; +/// Every EQL type a plan may name as a target, in +/// `eql-domains::CATALOG` order: one per stored domain (every flat +/// scalar domain plus the SteVec document). Query twins are not +/// targets; each row names its own under `query`. +pub const TARGETS: &[Target] = &[ + Target { + name: "Integer", + family: "integer", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_integer", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerEq", + family: "integer", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_integer_eq", + indexes: &["eq"], + query: Some("IntegerEqQuery"), + query_sql_domain: Some("eql_v3.query_integer_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrdOre", + family: "integer", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord_ore", + indexes: &["ore"], + query: Some("IntegerOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrd", + family: "integer", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord", + indexes: &["ope"], + query: Some("IntegerOrdQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrdOpe", + family: "integer", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord_ope", + indexes: &["ope"], + query: Some("IntegerOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Smallint", + family: "smallint", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_smallint", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintEq", + family: "smallint", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_smallint_eq", + indexes: &["eq"], + query: Some("SmallintEqQuery"), + query_sql_domain: Some("eql_v3.query_smallint_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrdOre", + family: "smallint", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord_ore", + indexes: &["ore"], + query: Some("SmallintOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrd", + family: "smallint", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord", + indexes: &["ope"], + query: Some("SmallintOrdQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrdOpe", + family: "smallint", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord_ope", + indexes: &["ope"], + query: Some("SmallintOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Bigint", + family: "bigint", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_bigint", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintEq", + family: "bigint", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_bigint_eq", + indexes: &["eq"], + query: Some("BigintEqQuery"), + query_sql_domain: Some("eql_v3.query_bigint_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrdOre", + family: "bigint", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord_ore", + indexes: &["ore"], + query: Some("BigintOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrd", + family: "bigint", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord", + indexes: &["ope"], + query: Some("BigintOrdQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrdOpe", + family: "bigint", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord_ope", + indexes: &["ope"], + query: Some("BigintOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Date", + family: "date", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_date", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateEq", + family: "date", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_date_eq", + indexes: &["eq"], + query: Some("DateEqQuery"), + query_sql_domain: Some("eql_v3.query_date_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrdOre", + family: "date", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_date_ord_ore", + indexes: &["ore"], + query: Some("DateOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_date_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrd", + family: "date", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_date_ord", + indexes: &["ope"], + query: Some("DateOrdQuery"), + query_sql_domain: Some("eql_v3.query_date_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrdOpe", + family: "date", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_date_ord_ope", + indexes: &["ope"], + query: Some("DateOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_date_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Timestamp", + family: "timestamp", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_timestamp", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampEq", + family: "timestamp", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_eq", + indexes: &["eq"], + query: Some("TimestampEqQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrdOre", + family: "timestamp", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord_ore", + indexes: &["ore"], + query: Some("TimestampOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrd", + family: "timestamp", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord", + indexes: &["ope"], + query: Some("TimestampOrdQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrdOpe", + family: "timestamp", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord_ope", + indexes: &["ope"], + query: Some("TimestampOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Numeric", + family: "numeric", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_numeric", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericEq", + family: "numeric", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_numeric_eq", + indexes: &["eq"], + query: Some("NumericEqQuery"), + query_sql_domain: Some("eql_v3.query_numeric_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrdOre", + family: "numeric", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord_ore", + indexes: &["ore"], + query: Some("NumericOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrd", + family: "numeric", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord", + indexes: &["ope"], + query: Some("NumericOrdQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrdOpe", + family: "numeric", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord_ope", + indexes: &["ope"], + query: Some("NumericOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Text", + family: "text", + suffix: "", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL", + ), + }, + Target { + name: "TextEq", + family: "text", + suffix: "Eq", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_eq", + indexes: &["eq"], + query: Some("TextEqQuery"), + query_sql_domain: Some("eql_v3.query_text_eq"), + producible: true, + reason: None, + }, + Target { + name: "TextMatch", + family: "text", + suffix: "Match", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_match", + indexes: &["match"], + query: Some("TextMatchQuery"), + query_sql_domain: Some("eql_v3.query_text_match"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextOrdOre", + family: "text", + suffix: "OrdOre", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord_ore", + indexes: &["eq", "ore"], + query: Some("TextOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_text_ord_ore"), + producible: false, + reason: Some( + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms", + ), + }, + Target { + name: "TextOrd", + family: "text", + suffix: "Ord", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord", + indexes: &["eq", "ope"], + query: Some("TextOrdQuery"), + query_sql_domain: Some("eql_v3.query_text_ord"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextOrdOpe", + family: "text", + suffix: "OrdOpe", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord_ope", + indexes: &["eq", "ope"], + query: Some("TextOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_text_ord_ope"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextSearchOre", + family: "text", + suffix: "SearchOre", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_search_ore", + indexes: &["eq", "ore", "match"], + query: Some("TextSearchOreQuery"), + query_sql_domain: Some("eql_v3.query_text_search_ore"), + producible: false, + reason: Some( + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms", + ), + }, + Target { + name: "TextSearch", + family: "text", + suffix: "Search", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_search", + indexes: &["eq", "ope", "match"], + query: Some("TextSearchQuery"), + query_sql_domain: Some("eql_v3.query_text_search"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "Boolean", + family: "boolean", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_boolean", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Real", + family: "real", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_real", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealEq", + family: "real", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_real_eq", + indexes: &["eq"], + query: Some("RealEqQuery"), + query_sql_domain: Some("eql_v3.query_real_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrdOre", + family: "real", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_real_ord_ore", + indexes: &["ore"], + query: Some("RealOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_real_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrd", + family: "real", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_real_ord", + indexes: &["ope"], + query: Some("RealOrdQuery"), + query_sql_domain: Some("eql_v3.query_real_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrdOpe", + family: "real", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_real_ord_ope", + indexes: &["ope"], + query: Some("RealOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_real_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Double", + family: "double", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_double", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleEq", + family: "double", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_double_eq", + indexes: &["eq"], + query: Some("DoubleEqQuery"), + query_sql_domain: Some("eql_v3.query_double_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrdOre", + family: "double", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_double_ord_ore", + indexes: &["ore"], + query: Some("DoubleOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_double_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrd", + family: "double", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_double_ord", + indexes: &["ope"], + query: Some("DoubleOrdQuery"), + query_sql_domain: Some("eql_v3.query_double_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrdOpe", + family: "double", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_double_ord_ope", + indexes: &["ope"], + query: Some("DoubleOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_double_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SteVecDocument", + family: "json", + suffix: "Search", + plaintext: None, + sql_domain: "public.eql_v3_json_search", + indexes: &["json"], + query: Some("SteVecQuery"), + query_sql_domain: Some("eql_v3.query_json"), + producible: false, + reason: Some("the JSON index is a new operation in the engine"), + }, + Target { + name: "Json", + family: "json", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_json", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, +]; +/// Run the named type's own encryption plan for one field, or refuse +/// the name: the type is not producible (`TargetError::Unproducible`) +/// or does not exist (`TargetError::Unknown`). +pub(crate) fn encrypt_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + match name { + "TextEq" => { + run_target::("TextEq", keyset, column, plaintext) + } + _ => Err(refuse(name)), + } +} +/// Open a stored value of the named type back to its plaintext, or +/// refuse the name as `encrypt_named` does. +pub(crate) fn decrypt_named<'a, K: 'static>( + name: &str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], +) -> Result, TargetError> { + match name { + "TextEq" => open_target::("TextEq", opener, column, stored), + _ => Err(refuse(name)), + } +} +/// Run the named type's query twin for one plaintext, or refuse the +/// name: as `encrypt_named` does, or as answering no query +/// (`TargetError::NoQuery`) for a producible type with no twin. +pub(crate) fn query_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + match name { + "TextEq" => { + run_target::("TextEq", keyset, column, plaintext) + } + _ => Err(refuse_query(name)), + } +} diff --git a/packages/eql/crates/eql-codegen/src/bindings.rs b/packages/eql/crates/eql-codegen/src/bindings.rs index 5da192264..d785beccf 100644 --- a/packages/eql/crates/eql-codegen/src/bindings.rs +++ b/packages/eql/crates/eql-codegen/src/bindings.rs @@ -574,7 +574,8 @@ pub fn render_inventory_rs() -> String { /// shapes are inventory members but not stored column payloads, so they are /// excluded — exactly the set `eql_bindings::from_v2` accepts as conversion /// targets ([`render_payload_rs`]'s `DomainPayload` variants). -fn stored_payload_domains() -> impl Iterator { +pub(crate) fn stored_payload_domains( +) -> impl Iterator { CATALOG .iter() .flat_map(|f| f.domains.iter().map(move |d| (f, d))) @@ -856,6 +857,9 @@ fn render_bindings(dir: &Path) -> Vec<(PathBuf, String)> { } rendered.push((dir.join("payload.rs"), render_payload_rs())); rendered.push((dir.join("query_payload.rs"), render_query_payload_rs())); + // The stack-encrypt target table and dispatch (`crate::targets`); gated + // to the `stack-encrypt` feature by the hand-written mod.rs. + rendered.push((dir.join("targets.rs"), crate::targets::render_targets_rs())); rendered.push((dir.join("inventory.rs"), render_inventory_rs())); rendered } @@ -1109,12 +1113,14 @@ mod tests { let tmp = crate::writer::test_support::tempdir(); let written = generate_bindings(tmp.path()).unwrap(); let dir = tmp.path().join("crates/eql-bindings/src/v3"); - // scalar families + jsonb_storage + payload + query_payload + inventory. - assert_eq!(written.len(), eql_domains::scalar_families().count() + 4); + // scalar families + jsonb_storage + payload + query_payload + targets + // + inventory. + assert_eq!(written.len(), eql_domains::scalar_families().count() + 5); assert!(dir.join("integer.rs").is_file()); assert!(dir.join("text.rs").is_file()); assert!(dir.join("json_storage.rs").is_file()); assert!(dir.join("payload.rs").is_file()); + assert!(dir.join("targets.rs").is_file()); assert!(dir.join("inventory.rs").is_file()); assert!( !dir.join("mod.rs").exists(), @@ -1135,7 +1141,8 @@ mod tests { // source, so a render panic aborts before deletion. Lock in the // load-bearing property: render writes NOTHING to disk. A pre-existing // file in the target dir survives the render call untouched, and render - // returns one entry per family plus payload and inventory (last). + // returns one entry per family plus payload, query_payload, targets + // and inventory (last). let tmp = crate::writer::test_support::tempdir(); let dir = tmp.path().join(V3_BINDINGS_DIR); std::fs::create_dir_all(&dir).unwrap(); @@ -1144,7 +1151,7 @@ mod tests { let rendered = render_bindings(&dir); - assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 4); + assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 5); assert_eq!( std::fs::read_to_string(&sentinel).unwrap(), "SENTINEL", @@ -1413,8 +1420,8 @@ mod tests { "the json family's scalar storage domain must generate json_storage.rs" ); // One file per scalar family + jsonb_storage + payload + query_payload + - // inventory. - assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 4); + // targets + inventory. + assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 5); } #[test] diff --git a/packages/eql/crates/eql-codegen/src/go_eql.rs b/packages/eql/crates/eql-codegen/src/go_eql.rs new file mode 100644 index 000000000..0389e2921 --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/go_eql.rs @@ -0,0 +1,220 @@ +//! The Go `encrypt/eql` package emitter: renders `eql_domains::CATALOG` to +//! the committed `languages/golang/encrypt/eql/eql_gen.go` in the Go module +//! — one Go type per EQL type, holding the value as the JSON bytes its +//! column stores, the query types that match them, and a `Types` table a +//! generator or a program can consult. The plan +//! (`docs/plans/2026-10-04-plan-builder.md`, "EQL types") puts the package +//! here: "eql-codegen writes the package encrypt/eql from the EQL catalog". +//! +//! The rows are [`crate::targets`]', the same the Rust `TARGETS` table is +//! rendered from, so the Go table and the engine's `se_targets` cannot +//! disagree about a type. The Go names follow the catalog names with one +//! rule: the JSON family's begin with `JSON` (`Json` is `JSON`), as Go +//! spells initialisms; `stashgen` applies the same rule when it writes a +//! field's type. +//! +//! Generate-to-file like the Rust bindings: `eql-codegen go-eql` writes the +//! file, `mise run types:check` diffs it, and `tests/go_eql_parity.rs` +//! asserts the committed file is what the catalog renders. The output is +//! gofmt-clean by construction — one-line table rows and block-bodied +//! methods, the two shapes gofmt does not realign — and the Go CI's +//! `gofmt -l` is the check that it stayed so. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use crate::targets::{rows, Row}; + +/// The generated file, relative to the EQL subtree root (`repo_root()`): +/// the Go module lives beside `packages/` in the monorepo. +pub const GO_EQL_PATH: &str = "../../languages/golang/encrypt/eql/eql_gen.go"; + +/// The header every generated Go file carries: the `Code generated ... DO +/// NOT EDIT.` form `go vet` and editors recognise. +pub const GO_GENERATED_MARKER: &str = + "// Code generated by eql-codegen from the eql-domains catalog. DO NOT EDIT."; + +/// The Go type name of a catalog name: the same, save the JSON family. +pub fn go_name(name: &str) -> String { + match name.strip_prefix("Json") { + Some(rest) => format!("JSON{rest}"), + None => name.to_owned(), + } +} + +/// A Go string literal. +fn quote(s: &str) -> String { + format!("{s:?}") +} + +/// A Go `[]string` literal, `nil` when empty. +fn strings(items: &[&str]) -> String { + if items.is_empty() { + return "nil".to_owned(); + } + let quoted: Vec = items.iter().map(|s| quote(s)).collect(); + format!("[]string{{{}}}", quoted.join(", ")) +} + +fn table_row(row: &Row) -> String { + let (query, query_domain) = row + .query + .as_ref() + .map(|(q, d)| (q.as_str(), d.as_str())) + .unwrap_or_default(); + format!( + "\t{{Name: {}, GoName: {}, Family: {}, Suffix: {}, Plaintext: {}, SQLDomain: {}, \ + Indexes: {}, Query: {}, QuerySQLDomain: {}, Producible: {}, Reason: {}}},\n", + quote(&row.name), + quote(&go_name(&row.name)), + quote(row.family), + quote(&row.suffix), + quote(row.plaintext.unwrap_or_default()), + quote(&row.sql_domain), + strings(&row.indexes), + quote(query), + quote(query_domain), + row.reason.is_none(), + quote(row.reason.unwrap_or_default()), + ) +} + +/// The four methods every EQL type and query type carries, block-bodied so +/// gofmt leaves them as written. +fn methods(out: &mut String, go: &str, domain: &str) { + let _ = write!( + out, + "\n// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty.\n\ + func (v {go}) Value() (driver.Value, error) {{\n\treturn value(v)\n}}\n\ + \n// Scan reads a {domain} column.\n\ + func (v *{go}) Scan(src any) error {{\n\treturn scan((*[]byte)(v), src, {q})\n}}\n\ + \n// MarshalJSON is the EQL value as the JSON it is, or null when empty.\n\ + func (v {go}) MarshalJSON() ([]byte, error) {{\n\treturn marshal(v, {q})\n}}\n\ + \n// UnmarshalJSON reads the EQL value from JSON, as it is.\n\ + func (v *{go}) UnmarshalJSON(b []byte) error {{\n\treturn unmarshal((*[]byte)(v), b)\n}}\n", + q = quote(go), + ); +} + +/// Render the Go source of `eql_gen.go`. +pub fn render_go_eql() -> String { + let rows = rows(); + let mut out = String::new(); + let _ = writeln!( + out, + "{GO_GENERATED_MARKER}\n\npackage eql\n\nimport \"database/sql/driver\"\n" + ); + out.push_str( + "// Types is every EQL type the catalog has, in catalog order: what the\n\ + // engine's se_targets export lists, as Go. Producible says whether the\n\ + // engine produces the type today; stashgen refuses encrypt_into for one\n\ + // it does not, with the Reason.\n\ + var Types = []Type{\n", + ); + for row in &rows { + out.push_str(&table_row(row)); + } + out.push_str("}\n"); + for row in &rows { + let go = go_name(&row.name); + let indexes = if row.indexes.is_empty() { + "no index".to_owned() + } else { + format!( + "the {} index{}", + row.indexes.join(" and "), + if row.indexes.len() > 1 { "es" } else { "" } + ) + }; + let status = match (row.reason, row.plaintext) { + (None, Some(kind)) => format!("The engine produces it from a {kind}."), + (None, None) => "The engine produces it.".to_owned(), + (Some(reason), _) => format!("The engine cannot produce it yet: {reason}."), + }; + let _ = write!( + out, + "\n// {go} is the EQL type stored in {domain}: the {family} family with {indexes}.\n// {status}\ntype {go} []byte\n", + domain = row.sql_domain, + family = row.family, + ); + methods(&mut out, &go, &row.sql_domain); + if let Some((query, query_domain)) = &row.query { + let query_go = go_name(query); + let _ = write!( + out, + "\n// {query_go} is the query value for {go}: the operand {query_domain} takes.\ntype {query_go} []byte\n", + ); + methods(&mut out, &query_go, query_domain); + } + } + out +} + +/// Write `eql_gen.go` under `out_root` (the EQL subtree root, or the +/// override `EQL_CODEGEN_OUT_ROOT`). Returns the path written. +pub fn generate_go_eql(out_root: &Path) -> std::io::Result { + let path = out_root.join(GO_EQL_PATH); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, render_go_eql())?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn go_names_follow_the_catalog_save_the_json_family() { + assert_eq!(go_name("TextEq"), "TextEq"); + assert_eq!(go_name("TextEqQuery"), "TextEqQuery"); + assert_eq!(go_name("Json"), "JSON"); + assert_eq!(go_name("SteVecDocument"), "SteVecDocument"); + } + + #[test] + fn the_package_has_one_type_per_row_and_one_query_type_per_twin() { + let out = render_go_eql(); + assert!(out.starts_with(GO_GENERATED_MARKER)); + assert!(out.contains("\npackage eql\n")); + for row in rows() { + let go = go_name(&row.name); + assert_eq!( + out.matches(&format!("\ntype {go} []byte\n")).count(), + 1, + "{go}" + ); + assert!( + out.contains(&format!("Name: {:?}, GoName: {go:?}", row.name)), + "{go} in the table" + ); + if let Some((query, _)) = &row.query { + let query_go = go_name(query); + assert_eq!( + out.matches(&format!("\ntype {query_go} []byte\n")).count(), + 1, + "{query_go}" + ); + } + } + // The one query type two rows share (text_ord and text_ord_ope + // have distinct names; the SteVec needle belongs to one document), + // so no type is declared twice. + assert_eq!(out.matches("\ntype JSON []byte\n").count(), 1); + assert_eq!(out.matches("\ntype SteVecQuery []byte\n").count(), 1); + // TextEq is producible and says so; TextOrdOre is not and says why. + assert!(out.contains("// TextEq is the EQL type stored in public.eql_v3_text_eq")); + assert!(out.contains("Producible: true, Reason: \"\"")); + assert!(out.contains("Name: \"TextOrdOre\"") && out.contains("CLLW")); + // Every table row is one line: the shape gofmt leaves alone. + for line in out.lines().filter(|l| l.starts_with("\t{Name: ")) { + assert!(line.ends_with("},"), "{line}"); + } + } + + #[test] + fn rendering_is_deterministic() { + assert_eq!(render_go_eql(), render_go_eql()); + } +} diff --git a/packages/eql/crates/eql-codegen/src/lib.rs b/packages/eql/crates/eql-codegen/src/lib.rs index f05c70bfa..03cdf3e48 100644 --- a/packages/eql/crates/eql-codegen/src/lib.rs +++ b/packages/eql/crates/eql-codegen/src/lib.rs @@ -12,8 +12,10 @@ pub mod consts; pub mod context; pub mod dump; pub mod generate; +pub mod go_eql; pub mod operator_surface; pub mod ordering; +pub mod targets; pub mod writer; /// The repository root, derived from this crate's manifest dir (the generator diff --git a/packages/eql/crates/eql-codegen/src/main.rs b/packages/eql/crates/eql-codegen/src/main.rs index 1fc7eba0a..2dc95b195 100644 --- a/packages/eql/crates/eql-codegen/src/main.rs +++ b/packages/eql/crates/eql-codegen/src/main.rs @@ -69,6 +69,23 @@ fn main() -> ExitCode { } } + // `go-eql`: regenerate the committed Go package encrypt/eql in the Go + // module (languages/golang) from the catalog: one Go type per EQL type, + // the query types, and the Types table. Wired into `mise run + // types:generate` beside `bindings`; `types:check` diffs the file. + if args.len() == 2 && args[1] == "go-eql" { + match eql_codegen::go_eql::generate_go_eql(&out_root()) { + Ok(path) => { + println!("generated {}", path.display()); + return ExitCode::SUCCESS; + } + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + } + } + // `order`: print the install order of the whole src/v3 SQL surface, one // repo-relative path per line, dependency before dependent. Consumed by // tasks/build.sh (`> src/deps-ordered-v3.txt`), which concatenates the files @@ -139,5 +156,6 @@ fn main() -> ExitCode { eprintln!(" eql-codegen list-schemas (print owned schemas, public first)"); eprintln!(" eql-codegen dump-catalog (print catalog surface as JSON)"); eprintln!(" eql-codegen bindings (regenerate eql-bindings Rust payload types)"); + eprintln!(" eql-codegen go-eql (regenerate the Go package encrypt/eql)"); ExitCode::from(2) } diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs new file mode 100644 index 000000000..959a71a86 --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -0,0 +1,551 @@ +//! The target-dispatch emitter: renders `eql_domains::CATALOG` to the +//! committed `crates/eql-bindings/src/v3/targets.rs` — the table of EQL +//! types a Stack Encrypt data plan may name as a field target, and the +//! by-name dispatch that runs a named type's own Rust plan. Generated beside +//! `inventory.rs` by the same mechanism ([`crate::bindings`]), and gated to +//! the `stack-encrypt` feature by the hand-written `v3/mod.rs`. +//! +//! The table is the stack-encrypt view of the catalog: for every stored +//! domain, the type's name in every language (`TextEq`), its family and +//! suffix, the plaintext kind it accepts, the indexes it carries, its query +//! twin, and whether the engine can produce it today — with the reason when +//! not. The dispatch has an arm for exactly the producible types, which are +//! exactly the domains carrying the `stack-encrypt` derives +//! ([`bindings::ENCRYPTION_DOMAINS`](crate::bindings::ENCRYPTION_DOMAINS)): +//! an arm runs `>::encryption()`, which only exists with +//! the derive, and [`target_gap`] is tested to agree with +//! [`encryption_gap`](crate::bindings::encryption_gap) on every domain. +//! +//! The reasons a type is not producible are the plan's +//! (`docs/plans/2026-10-04-plan-builder.md`, "EQL types"): every family but +//! text has no specified plaintext encoding; match and OPE terms are derived +//! but no EQL type is built from them; EQL's ORE term is block ORE where the +//! engine derives CLLW ORE; and the JSON index is a new engine operation. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use eql_domains::{Domain, DomainFamily, Shape, Term}; + +use crate::bindings::{encryption_gap, format_rs, stored_payload_domains}; + +/// The `IndexSpec::key()` a catalog term rides under in a plan — the +/// `"eq"` / `"match"` / `"ore"` / `"ope"` strings of stack-encrypt's data +/// grammar — so a Go generator can match a target's indexes against the +/// indexes a plan field may ask for by the same names. Exhaustive on +/// purpose: a new catalog term must say which engine index it is. +pub fn index_key(term: Term) -> &'static str { + match term { + Term::Hm => "eq", + Term::Bloom => "match", + Term::Ore => "ore", + Term::Ope => "ope", + } +} + +/// The index a SteVec document carries: the engine's JSON index, which has +/// no catalog `Term` because its terms live per `sv` leaf, not as flat +/// payload keys. +const JSON_INDEX_KEY: &str = "json"; + +/// The plaintext a family's domains are produced from, as a vitaminc +/// `ValueKind` name (what a plan field's `"type"` key spells) and the Rust +/// type the generated dispatch names. `None` while the family's plaintext +/// encoding for the stack-encrypt producer profile is unspecified, which is +/// every family but text today. +pub fn plaintext(family: &DomainFamily) -> Option<(&'static str, &'static str)> { + plaintext_of(family.name) +} + +/// [`plaintext`], by family name: what a rendered row carries. +fn plaintext_of(family: &str) -> Option<(&'static str, &'static str)> { + (family == "text").then_some(("string", "String")) +} + +/// Why the engine cannot produce a stored domain's EQL type today, or +/// `None` when it can. `None` exactly when the domain carries the +/// `stack-encrypt` derives ([`encryption_gap`]), because the dispatch runs +/// the derived plan; the reasons are the plan's, keyed on catalog facts so +/// a domain added tomorrow gets an answer. +pub fn target_gap(family: &DomainFamily, domain: &Domain) -> Option<&'static str> { + encryption_gap(family, domain)?; + Some(if matches!(domain.shape, Shape::SteVec) { + "the JSON index is a new operation in the engine" + } else if family.name != "text" { + "how this family encodes a plaintext for the stack-encrypt producer profile is not \ + specified; only the text family's encoding is" + } else if domain.terms.contains(&Term::Ore) { + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are \ + different algorithms" + } else if domain + .terms + .iter() + .any(|t| matches!(t, Term::Bloom | Term::Ope)) + { + "the engine derives match and OPE terms, but no EQL type is built from them yet" + } else if domain.terms.is_empty() { + "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL" + } else { + "an equality-only text domain derives the same way TextEq does; it is not listed in \ + ENCRYPTION_DOMAINS yet" + }) +} + +/// One row of the generated table, computed from the catalog: what both +/// the Rust `TARGETS` table and the Go `encrypt/eql` package +/// ([`crate::go_eql`]) are rendered from, so the two cannot disagree. +pub(crate) struct Row { + pub(crate) name: String, + pub(crate) family: &'static str, + pub(crate) suffix: String, + pub(crate) plaintext: Option<&'static str>, + pub(crate) sql_domain: String, + pub(crate) indexes: Vec<&'static str>, + /// The query twin's struct identifier and SQL domain, if the type + /// answers a query. + pub(crate) query: Option<(String, String)>, + pub(crate) reason: Option<&'static str>, +} + +/// Every row, in catalog order: one per stored domain. +pub(crate) fn rows() -> Vec { + stored_payload_domains().map(|(f, d)| row(f, d)).collect() +} + +/// The PascalCase of a bare domain name — `TextOrdOre` is family `text`, +/// suffix `OrdOre`; the storage domain's suffix is empty. The same mangler +/// as the struct identifier, run over the bare name alone: a scalar-shaped +/// `Domain` under an empty family name is `_`, and `struct_ident` +/// drops the empty segment. +fn suffix(domain: &Domain) -> String { + Domain { + name: domain.name, + terms: &[], + shape: Shape::Scalar, + } + .struct_ident("") +} + +fn row(family: &'static DomainFamily, domain: &'static Domain) -> Row { + let is_stevec = matches!(domain.shape, Shape::SteVec); + let indexes = if is_stevec { + vec![JSON_INDEX_KEY] + } else { + Term::payload_terms(domain.terms) + .into_iter() + .map(index_key) + .collect() + }; + let query = if is_stevec { + // The containment needle is the document's query form. Its struct + // is the hand-written `SteVecQuery`; its domain is the family's + // `query` domain (`eql_v3.query_json`), read from the catalog so + // the name is spelled once. + family + .domains + .iter() + .find(|d| matches!(d.shape, Shape::SteVec) && d.name == "query") + .map(|needle| { + ( + needle.rust_struct_name(family.name), + format!("eql_v3.{}", needle.full_name(family.name)), + ) + }) + } else if domain.terms.is_empty() { + None + } else { + Some(( + format!("{}Query", domain.struct_ident(family.name)), + format!("eql_v3.{}", domain.query_name(family.name)), + )) + }; + Row { + name: domain.rust_struct_name(family.name), + family: family.name, + suffix: suffix(domain), + plaintext: plaintext(family).map(|(kind, _)| kind), + sql_domain: format!("public.{}", domain.sql_typname(family.name)), + indexes, + query, + reason: target_gap(family, domain), + } +} + +fn option_str(value: Option<&str>) -> TokenStream { + match value { + Some(s) => quote!(Some(#s)), + None => quote!(None), + } +} + +/// Render the generated `crates/eql-bindings/src/v3/targets.rs`. +pub fn render_targets_rs() -> String { + render_targets_from(&rows()) +} + +/// Render the target table and dispatch from the given rows: the catalog's +/// in [`render_targets_rs`], a synthetic set in tests (a producible type +/// with no query twin is not in the catalog today). +fn render_targets_from(rows: &[Row]) -> String { + let entries: TokenStream = rows + .iter() + .map(|r| { + let name = &r.name; + let family = r.family; + let suffix = &r.suffix; + let plaintext = option_str(r.plaintext); + let sql_domain = &r.sql_domain; + let indexes = &r.indexes; + let query = option_str(r.query.as_ref().map(|(n, _)| n.as_str())); + let query_sql_domain = option_str(r.query.as_ref().map(|(_, d)| d.as_str())); + let producible = r.reason.is_none(); + let reason = option_str(r.reason); + quote! { + Target { + name: #name, + family: #family, + suffix: #suffix, + plaintext: #plaintext, + sql_domain: #sql_domain, + indexes: &[#(#indexes),*], + query: #query, + query_sql_domain: #query_sql_domain, + producible: #producible, + reason: #reason, + }, + } + }) + .collect(); + + // One arm per producible type. The plaintext Rust type is the family's; + // a producible family always has one, since a derive names it. + let producible: Vec<&Row> = rows.iter().filter(|r| r.reason.is_none()).collect(); + let arm = |r: &Row, strukt: &str| { + let name = &r.name; + let module = format_ident!("{}", r.family); + let ty = format_ident!("{strukt}"); + let (_, rust) = + plaintext_of(r.family).expect("a producible family has a specified plaintext"); + let source = format_ident!("{rust}"); + (name.clone(), quote!(super::#module::#ty), quote!(#source)) + }; + let encrypt_arms: TokenStream = producible + .iter() + .map(|r| { + let (name, ty, source) = arm(r, &r.name); + quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), } + }) + .collect(); + let decrypt_arms: TokenStream = producible + .iter() + .map(|r| { + let (name, ty, source) = arm(r, &r.name); + quote! { #name => open_target::<#ty, #source, K>(#name, opener, column, stored), } + }) + .collect(); + // A producible type with no query twin (a storage-only domain) gets no + // query arm: the fall-through refuses it as answering no query. + let query_arms: TokenStream = producible + .iter() + .filter_map(|r| { + let (query, _) = r.query.as_ref()?; + let (name, ty, source) = arm(r, query); + Some(quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), }) + }) + .collect(); + let helpers = if producible.is_empty() { + quote!() + } else { + quote!(open_target, run_target,) + }; + + let mod_doc = " The EQL types a Stack Encrypt data plan may name as a field target — \ + every stored v3 domain type in eql-domains::CATALOG order, as data a \ + guest serializes (`TARGETS`), with the by-name dispatch that runs a \ + producible type's own Rust plan. Generated from the catalog; the \ + descriptor type, the errors and the public entry points stay \ + hand-written in `crate::encryption::targets`, which documents the \ + wire format."; + + let file = quote! { + #![doc = #mod_doc] + + use vitaminc_aead_value::FfiValue; + + use crate::encryption::targets::{#helpers refuse, refuse_query, Opener, Target, TargetError}; + use crate::Identifier; + use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; + + /// Every EQL type a plan may name as a target, in + /// `eql-domains::CATALOG` order: one per stored domain (every flat + /// scalar domain plus the SteVec document). Query twins are not + /// targets; each row names its own under `query`. + pub const TARGETS: &[Target] = &[ + #entries + ]; + + /// Run the named type's own encryption plan for one field, or refuse + /// the name: the type is not producible (`TargetError::Unproducible`) + /// or does not exist (`TargetError::Unknown`). + pub(crate) fn encrypt_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + match name { + #encrypt_arms + _ => Err(refuse(name)), + } + } + + /// Open a stored value of the named type back to its plaintext, or + /// refuse the name as `encrypt_named` does. + pub(crate) fn decrypt_named<'a, K: 'static>( + name: &str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], + ) -> Result, TargetError> { + match name { + #decrypt_arms + _ => Err(refuse(name)), + } + } + + /// Run the named type's query twin for one plaintext, or refuse the + /// name: as `encrypt_named` does, or as answering no query + /// (`TargetError::NoQuery`) for a producible type with no twin. + pub(crate) fn query_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + match name { + #query_arms + _ => Err(refuse_query(name)), + } + } + }; + + format_rs(file) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bindings::ENCRYPTION_DOMAINS; + + /// The dispatch runs a derived plan, so a type is producible exactly + /// when its domain carries the derive. The two answers come from two + /// functions; this is what holds them together. + #[test] + fn a_target_is_producible_exactly_when_its_domain_has_a_derive() { + let mut producible = Vec::new(); + for (family, domain) in stored_payload_domains() { + let full = domain.full_name(family.name); + assert_eq!( + target_gap(family, domain).is_none(), + encryption_gap(family, domain).is_none(), + "{full}: producible and derived must agree" + ); + if target_gap(family, domain).is_none() { + producible.push((family.name, domain.name)); + assert!( + plaintext(family).is_some(), + "{full}: a producible family names its plaintext" + ); + } + } + assert_eq!( + producible, + ENCRYPTION_DOMAINS.to_vec(), + "the producible set is the derived set" + ); + } + + /// Every reason is the plan's reason for that domain, selected by the + /// catalog fact that applies: a wrong branch order would hand the ORE + /// reason to `text_search` or the encoding reason to a text domain. + #[test] + fn every_unproducible_target_has_the_plan_reason_for_its_domain() { + for (family, domain) in stored_payload_domains() { + let full = domain.full_name(family.name); + let Some(reason) = target_gap(family, domain) else { + continue; + }; + let expected = if matches!(domain.shape, Shape::SteVec) { + "JSON index" + } else if family.name != "text" { + "encodes a plaintext" + } else if domain.terms.contains(&Term::Ore) { + "block ORE" + } else if domain + .terms + .iter() + .any(|t| matches!(t, Term::Bloom | Term::Ope)) + { + "match and OPE terms" + } else { + assert!( + domain.terms.is_empty(), + "{full}: an hm-only text domain other than eq is not in the catalog" + ); + "storage-only text domain" + }; + assert!(reason.contains(expected), "{full}: {reason}"); + } + // Spelled out for the four plan rows, so the table reads without + // the derivation above. + let text = eql_domains::TEXT; + let by_name = |name: &str| text.domain_by_name(name).expect("text domain"); + assert!(target_gap(&text, by_name("ord_ore")) + .unwrap() + .contains("CLLW")); + assert!(target_gap(&text, by_name("search_ore")) + .unwrap() + .contains("CLLW")); + assert!(target_gap(&text, by_name("match")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("ord")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("ord_ope")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("search")) + .unwrap() + .contains("no EQL type")); + assert!( + target_gap(&text, by_name("eq")).is_none(), + "TextEq is producible" + ); + } + + #[test] + fn index_keys_are_the_engine_index_spec_keys() { + // The `IndexSpec::key()` strings of stack-encrypt's data grammar; + // a plan field spells an index by exactly these. + assert_eq!(index_key(Term::Hm), "eq"); + assert_eq!(index_key(Term::Bloom), "match"); + assert_eq!(index_key(Term::Ore), "ore"); + assert_eq!(index_key(Term::Ope), "ope"); + } + + #[test] + fn suffix_is_the_pascal_case_of_the_bare_domain_name() { + let text = eql_domains::TEXT; + let by_name = |name: &str| text.domain_by_name(name).expect("text domain"); + assert_eq!(suffix(by_name("")), ""); + assert_eq!(suffix(by_name("eq")), "Eq"); + assert_eq!(suffix(by_name("ord_ore")), "OrdOre"); + assert_eq!(suffix(by_name("search_ore")), "SearchOre"); + } + + #[test] + fn rendered_table_names_every_stored_domain_and_dispatches_the_producible_ones() { + let rendered = render_targets_rs(); + assert!(rendered.starts_with(crate::consts::RUST_GENERATED_MARKER)); + // rustfmt wraps a long arm over several lines; compare on one + // whitespace-collapsed line so the assertions below do not depend on + // where it broke. + let out: String = rendered.split_whitespace().collect::>().join(" "); + // One row per stored domain, in catalog order, named as the struct. + let mut last = 0; + for (family, domain) in stored_payload_domains() { + let needle = format!("name: \"{}\",", domain.rust_struct_name(family.name)); + let at = out[last..] + .find(&needle) + .unwrap_or_else(|| panic!("{needle} missing or out of order")); + last += at + needle.len(); + } + // The SteVec document carries the JSON index and the containment + // needle as its query form; the scalar storage domain carries none. + assert!(out.contains("name: \"SteVecDocument\"")); + assert!(out.contains("indexes: &[\"json\"]")); + assert!(out.contains("query: Some(\"SteVecQuery\")")); + assert!(out.contains("query_sql_domain: Some(\"eql_v3.query_json\")")); + assert!(out.contains("sql_domain: \"public.eql_v3_text_eq\"")); + assert!(out.contains("query_sql_domain: Some(\"eql_v3.query_text_eq\")")); + // Exactly the producible types have dispatch arms, in all three + // dispatches, and the query dispatch names the query twin. + for (family, domain) in ENCRYPTION_DOMAINS.iter().map(|(f, d)| { + let family = eql_domains::CATALOG + .iter() + .find(|x| x.name == *f) + .expect("family"); + (family, family.domain_by_name(d).expect("domain")) + }) { + let name = domain.rust_struct_name(family.name); + let module = family.name; + let arm = |helper: &str, ty: &str| { + format!( + "\"{name}\" => {{ {helper}::(\"{name}\", " + ) + }; + let flat_arm = |helper: &str, ty: &str| { + format!("\"{name}\" => {helper}::(\"{name}\", ") + }; + let count = |helper: &str, ty: &str| { + out.matches(&arm(helper, ty)).count() + out.matches(&flat_arm(helper, ty)).count() + }; + assert_eq!(count("run_target", &name), 1, "{name}: one encrypt arm"); + assert_eq!(count("open_target", &name), 1, "{name}: one decrypt arm"); + assert_eq!( + count("run_target", &format!("{name}Query")), + 1, + "{name}: one query arm" + ); + } + // No unproducible type has an arm: the only `=>` arms are the + // producible ones and the three fall-throughs. + let arms = out.matches("\" => ").count(); + assert_eq!(arms, ENCRYPTION_DOMAINS.len() * 3, "arms: {out}"); + assert_eq!(out.matches("_ => Err(refuse(name))").count(), 2); + assert_eq!(out.matches("_ => Err(refuse_query(name))").count(), 1); + } + + /// A producible type with no query twin — a storage-only domain, once + /// its derive lands — renders encrypt and decrypt arms and no query arm, + /// rather than aborting the generator: the query dispatch's fall-through + /// refuses it as answering no query. Not in the catalog today, so the + /// row is flipped by hand. + #[test] + fn a_producible_type_without_a_query_twin_gets_no_query_arm() { + let mut rows = rows(); + let text = rows + .iter_mut() + .find(|r| r.name == "Text") + .expect("the storage-only text domain"); + assert!(text.query.is_none() && text.reason.is_some()); + text.reason = None; + let out: String = render_targets_from(&rows) + .split_whitespace() + .collect::>() + .join(" "); + let arms_for = |helper: &str| { + out.matches(&format!( + "\"Text\" => {{ {helper}::(\"Text\"," + )) + .count() + + out + .matches(&format!( + "\"Text\" => {helper}::(\"Text\"," + )) + .count() + }; + assert_eq!(arms_for("run_target"), 1, "one encrypt arm: {out}"); + assert_eq!(arms_for("open_target"), 1, "one decrypt arm: {out}"); + assert!( + !out.contains("TextQuery"), + "no query arm for a type with no twin: {out}" + ); + assert_eq!( + out.matches("\" => ").count(), + (ENCRYPTION_DOMAINS.len() + 1) * 3 - 1, + "every producible type has three arms but the twinless one, which has two" + ); + } +} diff --git a/packages/eql/crates/eql-codegen/tests/cli.rs b/packages/eql/crates/eql-codegen/tests/cli.rs index 4beced971..4299ca989 100644 --- a/packages/eql/crates/eql-codegen/tests/cli.rs +++ b/packages/eql/crates/eql-codegen/tests/cli.rs @@ -35,7 +35,7 @@ fn tempdir() -> TempDir { /// the output-root override (test isolation) and never touches the committed /// `crates/eql-bindings/src/v3/*.rs`. The count is one file per scalar family /// plus the jsonb family's generated `jsonb_storage.rs`, `payload.rs`, -/// `query_payload.rs`, and `inventory.rs`. +/// `query_payload.rs`, `targets.rs`, and `inventory.rs`. #[test] fn bindings_subcommand_succeeds_and_reports_count() { let out_root = tempdir(); @@ -50,7 +50,7 @@ fn bindings_subcommand_succeeds_and_reports_count() { String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); - let expected = eql_domains::scalar_families().count() + 4; + let expected = eql_domains::scalar_families().count() + 5; assert!( stdout.contains(&format!("bindings: ok ({expected} files)")), "expected 'bindings: ok ({expected} files)' in stdout, got:\n{stdout}" diff --git a/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs b/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs new file mode 100644 index 000000000..96bf3878e --- /dev/null +++ b/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs @@ -0,0 +1,27 @@ +//! The Go `encrypt/eql` parity gate, the twin of `bindings_parity.rs`: the +//! committed `languages/golang/encrypt/eql/eql_gen.go` must be byte for byte +//! what the catalog renders. Without this, a catalog change would leave the +//! Go types behind until `mise run types:check` in CI, and a hand edit would +//! survive `cargo test`. + +use std::fs; + +use eql_codegen::go_eql::{render_go_eql, GO_EQL_PATH}; +use eql_codegen::repo_root; + +#[test] +fn go_eql_matches_the_committed_file() { + let path = repo_root().join(GO_EQL_PATH); + let committed = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "committed Go package {} is missing or unreadable ({e}); run `mise run types:generate` and commit", + path.display() + ) + }); + assert_eq!( + render_go_eql(), + committed, + "{}: the committed Go package is stale or hand-edited — run `mise run types:generate` and commit the result", + path.display() + ); +} diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 8f50e8bde..7d63a9b29 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -361,6 +361,9 @@ fi rm -rf "$tmp"' EXIT # Default no-arg eql-codegen stays SQL-only; `bindings` is the Rust-only subcommand. cargo run -q -p eql-codegen -- bindings +# The Go package encrypt/eql in the Go module, from the same catalog rows +# as the Rust target table (crates/eql-codegen/src/go_eql.rs). +cargo run -q -p eql-codegen -- go-eql TS_RS_EXPORT_DIR="$tmp/bindings" EQL_TYPES_SCHEMA_DIR="$tmp/schema" cargo test -p eql-bindings rm -rf "$bindings" "$schema" mv "$tmp/bindings" "$bindings" @@ -379,12 +382,12 @@ set -euo pipefail # runs in contexts (test-eql.yml's Rust job, the pre-commit hook) that # provision mise tools but not pnpm/node_modules. node packages/eql/scripts/sync-generated.mjs -git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated || { - echo "eql-bindings generated Rust/TS/JSON or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 +git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go || { + echo "eql-bindings generated Rust/TS/JSON, the Go package encrypt/eql or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 exit 1 } # git diff is blind to brand-new files; untracked output is stale too. -untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated) +untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go) if [ -n "$untracked" ]; then echo "eql-bindings has uncommitted generated files:" >&2 echo "$untracked" >&2 diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 4b95f0f20..ff99fb528 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -14,7 +14,13 @@ harness = false # Keep crypto/Postgres test dependencies out of eql-bindings' default test build. [dev-dependencies] eql-bindings = { path = "../../crates/eql-bindings", features = ["stack-encrypt"] } +# The parity oracle for the generated target table (tests/targets.rs): the +# table must name every stored catalog domain, in catalog order. +eql-domains = { path = "../../crates/eql-domains" } stack-encrypt = { path = "../../../stack-encrypt", default-features = false } +# `FfiValue`: the runtime plaintext the target dispatch takes. The same line +# eql-bindings names. +vitaminc-aead-value = "0.5.1" stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } serde = "1" serde_json = "1" diff --git a/packages/eql/tests/encryption/fixtures/text_eq_query.json b/packages/eql/tests/encryption/fixtures/text_eq_query.json new file mode 100644 index 000000000..ed65759f2 --- /dev/null +++ b/packages/eql/tests/encryption/fixtures/text_eq_query.json @@ -0,0 +1,7 @@ +{ + "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", + "column": "email", + "plaintext": "bob@example.com", + "query": "{\"v\":3,\"i\":{\"t\":\"users\",\"c\":\"email\"},\"hm\":\"ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646\"}", + "table": "users" +} diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs new file mode 100644 index 000000000..e5528b43a --- /dev/null +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -0,0 +1,627 @@ +//! EQL types as plan field targets, reached by name: the dispatch in +//! `eql_bindings::encryption::targets` must run the same plan the typed +//! `encrypt_as::` call runs, refuse what the table says it cannot +//! produce, and the table must be the catalog. + +mod common; + +use eql_bindings::encryption::targets::{self as targets, Opener, Target, TargetError}; +use eql_bindings::v3::text::{TextEq, TextEqQuery}; +use eql_bindings::{v3, Identifier}; +use eql_domains::{Shape, Term, CATALOG}; +use stack_encrypt::Label; +use vitaminc_aead_value::{FfiValue, ValueKind}; + +fn email() -> Label { + Label::new(["users", "email"]).unwrap() +} + +fn column() -> stack_encrypt::NonEmpty { + Identifier::for_column("users", "email").unwrap() +} + +fn text(value: &str) -> FfiValue { + FfiValue::String(value.into()) +} + +fn opened(value: FfiValue) -> String { + match value { + FfiValue::String(s) => std::str::from_utf8(s.risky_ref()).unwrap().to_owned(), + other => panic!("a text target opens to a string, not {:?}", other.kind()), + } +} + +/// The refusal a dispatch returned. `Pending` has no `Debug`, so this stands +/// in for `unwrap_err`; an accepted call is the test's failure. +fn refused(result: Result) -> TargetError { + match result { + Err(error) => error, + Ok(_) => panic!("the dispatch accepted what it should have refused"), + } +} + +mod given_text_eq { + use super::*; + + /// The dispatch and the typed call are the same plan: same identifier, + /// same equality term, and each side's value opens through the other. + /// The ciphertexts differ because every write seals under a fresh key, + /// so byte identity is asserted on everything but `c`. + #[tokio::test] + async fn produces_what_encrypt_as_produces() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + for value in ["alice@example.com", "", "雪☃", "cafe\u{301}"] { + let by_name = targets::encrypt("TextEq", &keyset, &email(), text(value)) + .unwrap() + .await + .unwrap(); + let by_name: TextEq = serde_json::from_slice(&by_name).unwrap(); + let typed: TextEq = keyset + .encrypt_as(&value.to_owned(), column()) + .await + .unwrap(); + assert_eq!(by_name.v, typed.v, "{value:?}: same envelope version"); + assert_eq!( + by_name.i, typed.i, + "{value:?}: the label is the stored identifier" + ); + assert_eq!(by_name.hm, typed.hm, "{value:?}: same equality term"); + assert_ne!( + by_name.c, typed.c, + "{value:?}: each write seals under a fresh key" + ); + + let typed_opened: String = cipher + .decrypt_as(by_name.clone(), column().into()) + .await + .unwrap(); + assert_eq!( + typed_opened, value, + "{value:?}: the typed path opens the named value" + ); + let named_opened = targets::decrypt( + "TextEq", + &cipher, + &email(), + &serde_json::to_vec(&typed).unwrap(), + ) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(named_opened), + value, + "{value:?}: the named path opens the typed value" + ); + } + let calls = calls.lock().unwrap(); + assert!( + calls.generate.iter().flatten().all(|d| d == "users/email"), + "both paths mint under the column's descriptor: {:?}", + calls.generate + ); + assert!( + calls.retrieve.iter().flatten().all(|d| d == "users/email"), + "both paths retrieve under the column's descriptor: {:?}", + calls.retrieve + ); + } + + #[tokio::test] + async fn round_trips_through_both_openers() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let through_client = targets::decrypt("TextEq", Opener::Client(&cipher), &email(), &stored) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(through_client), + "secret", + "the client opens its keyset's value" + ); + let through_keyset = targets::decrypt("TextEq", &keyset, &email(), &stored) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(through_keyset), + "secret", + "the keyset opens its own value" + ); + } + + /// A keyset opener refuses a value another keyset sealed, before any key + /// is retrieved; the client opens it. If `Opener::Keyset` opened through + /// the client, one tenant's cipher would read another's column. + #[tokio::test] + async fn a_keyset_opener_refuses_a_value_from_another_keyset() { + use stack_kms::IdentifiedBy; + let (cipher, calls) = common::cipher().await; + let named = |n: &str| IdentifiedBy::Name(n.to_string().into()); + let acme = cipher.keyset(named("acme")).await.unwrap(); + let globex = cipher.keyset(named("globex")).await.unwrap(); + let stored = targets::encrypt("TextEq", &acme, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let result = targets::decrypt("TextEq", &globex, &email(), &stored) + .unwrap() + .await; + match result { + Err(stack_encrypt::Error::ForeignKeyset { .. }) => {} + Err(other) => panic!("refused, but not as a foreign keyset: {other}"), + Ok(_) => panic!("globex opened acme's value"), + } + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "nothing was retrieved" + ); + let own = targets::decrypt("TextEq", &acme, &email(), &stored) + .unwrap() + .await + .expect("its own keyset opens it"); + assert_eq!(opened(own), "secret"); + let through_client = targets::decrypt("TextEq", &cipher, &email(), &stored) + .unwrap() + .await + .expect("the client opens any of its keysets' values"); + assert_eq!(opened(through_client), "secret"); + } + + #[tokio::test] + async fn query_bytes_are_the_typed_query_twin() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let by_name = targets::query("TextEq", &keyset, &email(), text("alice@example.com")) + .unwrap() + .await + .unwrap(); + let typed: TextEqQuery = keyset + .encrypt_as(&"alice@example.com".to_owned(), column()) + .await + .unwrap(); + assert_eq!( + by_name, + serde_json::to_vec(&typed).unwrap(), + "a query has no fresh key in it, so the bytes are identical" + ); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("alice@example.com")) + .unwrap() + .await + .unwrap(); + let stored: TextEq = serde_json::from_slice(&stored).unwrap(); + assert_eq!(typed.hm, stored.hm, "the query matches the stored value"); + let calls = calls.lock().unwrap(); + assert_eq!( + calls.generate.len(), + 1, + "only the stored value minted a key" + ); + assert!(calls.retrieve.is_empty(), "a query retrieves nothing"); + } + + #[tokio::test] + async fn opens_under_the_expected_column_only() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let other = Label::new(["users", "name"]).unwrap(); + let result = targets::decrypt("TextEq", &cipher, &other, &stored) + .unwrap() + .await; + match result { + Err(error) => assert!( + matches!(error, stack_encrypt::Error::ContextMismatch { .. }), + "a different expected column is refused before any key is retrieved: {error}" + ), + Ok(_) => panic!("a value stored under another column opened"), + } + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "nothing was retrieved" + ); + } + + #[tokio::test] + async fn refuses_a_value_of_another_kind_before_minting() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let error = refused(targets::encrypt( + "TextEq", + &keyset, + &email(), + FfiValue::UInt64(34), + )); + assert!( + matches!( + error, + TargetError::Plaintext { + target: "TextEq", + expected: ValueKind::String, + found: Some(ValueKind::UInt64) + } + ), + "{error}" + ); + let error = refused(targets::query("TextEq", &keyset, &email(), FfiValue::Null)); + assert!( + matches!(error, TargetError::Plaintext { found: None, .. }), + "{error}" + ); + assert!( + calls.lock().unwrap().generate.is_empty(), + "nothing was minted" + ); + } + + #[tokio::test] + async fn refuses_bytes_that_are_not_the_type() { + let (cipher, _) = common::cipher().await; + for bytes in [ + &b"not json"[..], + br#"{"v":3,"i":{"t":"users","c":"email"},"hm":"00"}"#, + ] { + let error = refused(targets::decrypt("TextEq", &cipher, &email(), bytes)); + assert!( + matches!( + error, + TargetError::Stored { + target: "TextEq", + .. + } + ), + "{error}" + ); + } + } + + #[tokio::test] + async fn refuses_a_label_that_is_not_a_column() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let tenant = Label::new(["tenant", "users", "email"]).unwrap(); + let error = refused(targets::encrypt("TextEq", &keyset, &tenant, text("x"))); + assert!( + matches!(&error, TargetError::Context { label } if label == "tenant/users/email"), + "{error}" + ); + } +} + +mod given_a_name_the_engine_cannot_produce { + use super::*; + + #[tokio::test] + async fn every_entry_point_refuses_it_with_the_table_reason() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let unproducible = |error: TargetError, name: &str| { + let reason = targets::target(name).unwrap().reason.unwrap(); + assert!( + matches!(&error, TargetError::Unproducible { name: n, reason: r } if *n == name && *r == reason), + "{name}: {error}" + ); + }; + for target in targets::targets().iter().filter(|t| !t.producible) { + let name = target.name; + unproducible( + refused(targets::encrypt(name, &keyset, &email(), text("x"))), + name, + ); + unproducible( + refused(targets::query(name, &keyset, &email(), text("x"))), + name, + ); + unproducible( + refused(targets::decrypt(name, &cipher, &email(), b"{}")), + name, + ); + } + let error = refused(targets::encrypt("TextOrdOre", &keyset, &email(), text("x"))); + assert_eq!( + error.to_string(), + "the engine cannot produce TextOrdOre yet: EQL stores a block ORE term and the engine \ + derives a CLLW ORE term; the two are different algorithms" + ); + // Refused by name before the label or the value is looked at. + let error = refused(targets::encrypt( + "TextOrdOre", + &keyset, + &Label::new(["one"]).unwrap(), + FfiValue::Null, + )); + assert!(matches!(error, TargetError::Unproducible { .. }), "{error}"); + let calls = calls.lock().unwrap(); + assert!( + calls.generate.is_empty() && calls.retrieve.is_empty(), + "no key was touched" + ); + } +} + +mod given_an_unknown_name { + use super::*; + + #[tokio::test] + async fn every_entry_point_says_no_such_type() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + for name in ["Nope", "texteq", "text_eq", "TextEqQuery", ""] { + let unknown = |error: TargetError| { + assert!( + matches!(&error, TargetError::Unknown { name: n } if n == name), + "{name:?}: {error}" + ); + }; + unknown(refused(targets::encrypt( + name, + &keyset, + &email(), + text("x"), + ))); + unknown(refused(targets::query(name, &keyset, &email(), text("x")))); + unknown(refused(targets::decrypt(name, &cipher, &email(), b"{}"))); + } + assert_eq!( + refused(targets::encrypt("Nope", &keyset, &email(), text("x"))).to_string(), + "no such EQL type: Nope" + ); + } +} + +mod the_table { + use super::*; + + /// The stored domains of the catalog, in catalog order: every scalar + /// domain plus the SteVec document — the rows the table must have. + fn stored_domains() -> Vec<( + &'static eql_domains::DomainFamily, + &'static eql_domains::Domain, + )> { + CATALOG + .iter() + .flat_map(|f| f.domains.iter().map(move |d| (f, d))) + .filter(|(f, d)| d.is_scalar() || d.full_name(f.name) == "json_search") + .collect() + } + + fn index_key(term: Term) -> &'static str { + match term { + Term::Hm => "eq", + Term::Bloom => "match", + Term::Ore => "ore", + Term::Ope => "ope", + } + } + + #[test] + fn names_every_stored_catalog_domain_in_order() { + let table = targets::targets(); + let expected = stored_domains(); + assert_eq!( + table.iter().map(|t| t.name).collect::>(), + expected + .iter() + .map(|(f, d)| d.rust_struct_name(f.name)) + .collect::>(), + "one row per stored domain, named as its struct, in catalog order" + ); + for (row, (family, domain)) in table.iter().zip(&expected) { + let name = row.name; + assert_eq!(row.family, family.name, "{name}: family"); + assert_eq!( + row.sql_domain, + format!("public.{}", domain.sql_typname(family.name)), + "{name}: stored domain" + ); + let indexes: Vec<&str> = if matches!(domain.shape, Shape::SteVec) { + vec!["json"] + } else { + Term::payload_terms(domain.terms) + .into_iter() + .map(index_key) + .collect() + }; + assert_eq!(row.indexes, indexes.as_slice(), "{name}: indexes"); + let suffix: String = domain + .name + .split('_') + .filter(|s| !s.is_empty()) + .map(|s| { + let mut c = s.chars(); + c.next().unwrap().to_uppercase().collect::() + c.as_str() + }) + .collect(); + assert_eq!(row.suffix, suffix, "{name}: suffix"); + assert_eq!( + row.plaintext, + (family.name == "text").then_some("string"), + "{name}: only text's plaintext encoding is specified" + ); + assert_eq!( + row.plaintext_kind(), + (family.name == "text").then_some(ValueKind::String), + "{name}: the plaintext name is a vitaminc kind" + ); + if matches!(domain.shape, Shape::SteVec) { + assert_eq!( + row.query, + Some("SteVecQuery"), + "{name}: the containment needle" + ); + assert_eq!(row.query_sql_domain, Some("eql_v3.query_json"), "{name}"); + } else if domain.terms.is_empty() { + assert_eq!( + (row.query, row.query_sql_domain), + (None, None), + "{name}: storage-only" + ); + } else { + assert_eq!( + row.query.map(str::to_owned), + Some(format!("{}Query", domain.struct_ident(family.name))), + "{name}: query twin" + ); + assert_eq!( + row.query_sql_domain.map(str::to_owned), + Some(format!("eql_v3.{}", domain.query_name(family.name))), + "{name}: query domain" + ); + } + } + } + + #[test] + fn every_row_is_a_compiled_domain_type_and_so_is_its_query() { + let stored: Vec<&str> = v3::all().iter().map(|d| d.sql_domain()).collect(); + let queries: Vec<&str> = v3::all_query().iter().map(|d| d.sql_domain()).collect(); + for row in targets::targets() { + assert!( + stored.contains(&row.sql_domain), + "{}: {} is in all()", + row.name, + row.sql_domain + ); + if let Some(query) = row.query_sql_domain { + // The SteVec needle is in `all()`, not `all_query()`: it is + // a hand-written document shape, not a scalar twin. + assert!( + queries.contains(&query) || stored.contains(&query), + "{}: {query} is a compiled domain type", + row.name + ); + } + } + } + + #[test] + fn text_eq_is_the_one_producible_type_today() { + let producible: Vec<&str> = targets::targets() + .iter() + .filter(|t| t.producible) + .map(|t| t.name) + .collect(); + assert_eq!( + producible, + ["TextEq"], + "the plan's list: the engine produces TextEq only" + ); + for row in targets::targets().iter().filter(|t| !t.producible) { + let reason = row.reason.expect("an unproducible type has a reason"); + let expected = match (row.family, row.suffix) { + ("json", "Search") => "JSON index", + (family, _) if family != "text" => "encodes a plaintext", + (_, "OrdOre" | "SearchOre") => "CLLW", + (_, "Match" | "Ord" | "OrdOpe" | "Search") => "no EQL type is built", + (_, "") => "storage-only", + (_, suffix) => panic!("{}: unexpected text suffix {suffix}", row.name), + }; + assert!(reason.contains(expected), "{}: {reason}", row.name); + } + } + + /// The `se_targets` wire format, pinned on one row: a field renamed or + /// dropped here is a change every reader of the export sees. + #[test] + fn serializes_as_the_documented_wire_format() { + let row: &Target = targets::target("TextEq").unwrap(); + assert_eq!( + serde_json::to_value(row).unwrap(), + serde_json::json!({ + "name": "TextEq", + "family": "text", + "suffix": "Eq", + "plaintext": "string", + "sql_domain": "public.eql_v3_text_eq", + "indexes": ["eq"], + "query": "TextEqQuery", + "query_sql_domain": "eql_v3.query_text_eq", + "producible": true, + "reason": null + }) + ); + let row = targets::target("Integer").unwrap(); + let json = serde_json::to_value(row).unwrap(); + assert_eq!( + json["plaintext"], + serde_json::Value::Null, + "an unspecified plaintext is null, not absent" + ); + assert_eq!(json["query"], serde_json::Value::Null); + assert_eq!(json["producible"], false); + assert!(json["reason"].is_string()); + // The whole table serializes as a list, the shape of the export. + let all = serde_json::to_value(targets::targets()).unwrap(); + assert_eq!(all.as_array().unwrap().len(), targets::targets().len()); + } +} + +mod the_cross_language_fixture { + use super::*; + use std::path::PathBuf; + + /// `fixtures/text_eq_query.json`: the `TextEqQuery` for one plaintext + /// under the key source every test in the repository derives terms + /// under (`FakeDataKeySource`'s index key, keyset nil), as the Rust + /// dispatch produces it. The Go SDK's hermetic test seals the same + /// plaintext through generated code over the deterministic guest build + /// — whose index key is the same — and asserts its `Fields.Email.Query` + /// bytes equal these, which is the cross-language half of the proof + /// that a Go `encrypt_into=TextEq` field runs this plan and no other. + /// Regenerate with `EQL_UPDATE_FIXTURES=1`; the bytes must not change + /// otherwise. + fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/text_eq_query.json") + } + + const PLAINTEXT: &str = "bob@example.com"; + + #[tokio::test] + async fn the_text_eq_query_fixture_is_what_the_dispatch_derives() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let query = targets::query("TextEq", &keyset, &email(), text(PLAINTEXT)) + .unwrap() + .await + .unwrap(); + assert!( + calls.lock().unwrap().generate.is_empty(), + "a query mints nothing" + ); + let fixture = serde_json::json!({ + "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", + "table": "users", + "column": "email", + "plaintext": PLAINTEXT, + "query": String::from_utf8(query.clone()).unwrap(), + }); + let rendered = serde_json::to_string_pretty(&fixture).unwrap() + "\n"; + if std::env::var_os("EQL_UPDATE_FIXTURES").is_some() { + std::fs::write(fixture_path(), &rendered).unwrap(); + } + let committed = std::fs::read_to_string(fixture_path()) + .expect("fixtures/text_eq_query.json is committed; EQL_UPDATE_FIXTURES=1 writes it"); + assert_eq!( + committed, rendered, + "the committed fixture is what the dispatch derives today" + ); + let typed: TextEqQuery = keyset + .encrypt_as(&PLAINTEXT.to_owned(), column()) + .await + .unwrap(); + assert_eq!( + query, + serde_json::to_vec(&typed).unwrap(), + "and what the typed path derives" + ); + } +} diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 8f0ecf0a6..b13bc06cb 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A data plan field may name an EQL type as its target.** Beside the + output form, `dynamic::record::plan_with` reads `{"context": [...], + "target": "TextEq", "type"?: ...}` — the two forms are exclusive — and + resolves the name through a `dynamic::TargetResolver` the host installs: + the EQL types a build holds (`targets()`, serializable as + `TargetDescriptor::to_value()` for a guest's `se_targets` export) and how + to run one. `encrypt_with` zips the type's own `Pending` into the record's, + so a record with target fields is still one ZeroKMS request, and stores + the EQL value's JSON bytes under the field's `"eql"` key + (`record::EQL_KEY`); `decrypt_with` opens it back through the resolver, + confined to the scope's keyset like every other leaf; `record::query` + derives a target field's query value. A name the resolver does not know + or cannot produce, a `"type"` other than the type's plaintext kind, and + an extended plan (an EQL value is stored under a table and a column, so + a tenant part has no column) are refused when the plan is built + (`Error::Target`, `TargetError`), so a guest's `se_plan_check` reports + them. `dynamic::NoTargets` is the resolver of a build without EQL types + and refuses every name; the bare `plan`, `encrypt` and `decrypt` run + under it. `FieldPlan::with_target`, `FieldPlan::target()`, + `Plan::new_with`. + ### Breaking - **A target description carries a source mode.** `Encryption` gains a diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 65a39e705..880a27eff 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2056,6 +2056,7 @@ dependencies = [ "libfuzzer-sys", "stack-encrypt", "uuid", + "vitaminc-protected", ] [[package]] diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index b4e81248a..5a3bca512 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -15,6 +15,9 @@ edition = "2021" cargo-fuzz = true [dependencies] +# `Protected`, for the check_record model's passthrough-of-bytes node: the +# same line stack-encrypt builds on. +vitaminc-protected = "0.5.1" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index 61edece76..3fcc29ee3 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -12,22 +12,80 @@ //! drawn from a small name alphabet so field names, output keys and map //! keys collide often, with passthroughs and duplicate keys anywhere. //! -//! Two invariants. Neither `plan` nor `check_record` may panic on any +//! Two invariants. Neither `plan_with` nor `check_record` may panic on any //! input. And for a plan that parses, `check_record` accepts the tree //! exactly when the model does — the rules from the record docs, written //! independently of the walk: one map, or a sequence of maps; every //! ciphertext-bearing plan field present exactly once in every row; that //! field a map with exactly one `"c"`; and under that `"c"` no passthrough -//! and no repeated map key at any depth. +//! and no repeated map key at any depth. A target field (one naming an EQL +//! type) is present exactly once with exactly one `"eql"` node, which is a +//! passthrough carrying bytes. +//! +//! Plans parse under a fixed resolver holding one producible type, `TextEq` +//! over strings, so the target checks in `Plan::new_with` and the `"eql"` +//! node read in `record_row` run under fuzzing; every other target name is +//! refused when the plan is built, as under `NoTargets`. use std::sync::OnceLock; use arbitrary::Arbitrary; use libfuzzer_sys::fuzz_target; -use stack_encrypt::dynamic::record::{check_record, plan, Plan}; -use stack_encrypt::dynamic::FfiValue; -use stack_encrypt::{SealedValue, StackCipherText}; +use stack_encrypt::dynamic::record::{check_record, plan_with, Plan}; +use stack_encrypt::dynamic::{ + FfiValue, TargetDescriptor, TargetError, TargetResolver, ValueKind, +}; +use stack_encrypt::{KeysetCipher, Label, Pending, SealedValue, StackCipher, StackCipherText}; use uuid::Uuid; +use vitaminc_protected::Protected; + +/// The resolver plans parse under: one producible type, `TextEq` over +/// strings, and nothing that runs — `check_record` never reaches a cipher. +struct Fixed; + +impl TargetResolver for Fixed { + fn targets(&self) -> Vec { + vec![TargetDescriptor::new( + "TextEq", + "text", + "Eq", + Some(ValueKind::String), + "public.eql_v3_text_eq", + vec!["eq".to_string()], + Some("TextEqQuery".to_string()), + Some("eql_v3.query_text_eq".to_string()), + true, + None, + )] + } + fn encrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("check_record runs no cipher") + } + fn decrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a StackCipher, + _: &Label, + _: &[u8], + ) -> Result, TargetError> { + unreachable!("check_record runs no cipher") + } + fn query<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("check_record runs no cipher") + } +} /// The name alphabet: the plan's field names, the tree's map keys and the /// output keys all draw from it, so `"c"` is at once an output key and a @@ -41,6 +99,10 @@ enum Name { Match, Ore, Ope, + /// The stored key of a target field's value, and a plausible field name. + Eql, + /// The one target name the fixed resolver produces. + TextEq, Other, } @@ -54,6 +116,8 @@ impl Name { Name::Match => "match", Name::Ore => "ore", Name::Ope => "ope", + Name::Eql => "eql", + Name::TextEq => "TextEq", Name::Other => "zz", } } @@ -92,14 +156,19 @@ impl Ctx { } } -/// One entry of a field spec: the two keys the parser knows and one it -/// does not, each with a value that may or may not be the right shape. +/// One entry of a field spec: the three keys the parser knows and one it +/// does not, each with a value that may or may not be the right shape. A +/// `"target"` names an EQL type; `check_record` runs under `NoTargets`, the +/// build without them, so every plan with one is refused when it is built +/// — the parser's path to that refusal is what this exercises. #[derive(Arbitrary, Debug)] enum SpecEntry { Context(Ctx), Outputs(Vec), + Target(Name), ContextWrongShape(u32), OutputsWrongShape(u32), + TargetWrongShape(u32), Unknown(Ctx), } @@ -116,8 +185,13 @@ impl SpecEntry { .collect(), ), ), + SpecEntry::Target(name) => ( + "target".to_string(), + FfiValue::String(name.as_str().into()), + ), SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)), SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)), + SpecEntry::TargetWrongShape(v) => ("target".to_string(), FfiValue::UInt32(v)), SpecEntry::Unknown(ctx) => ("bogus".to_string(), ctx.into_value()), } } @@ -145,7 +219,9 @@ impl PlanSpec { } } -/// A stored ciphertext tree, with every `CipherText` variant reachable. +/// A stored ciphertext tree, with every `CipherText` variant reachable. A +/// passthrough carries a null or bytes: a target field's `"eql"` node is a +/// passthrough of bytes, and one of anything else is refused. #[derive(Arbitrary, Debug)] enum Tree { Single, @@ -153,6 +229,7 @@ enum Tree { EmptySequence, EmptyMap, Passthrough, + PassthroughBytes, Sequence(Vec), Map(Vec<(Name, Tree)>), } @@ -176,6 +253,9 @@ impl Tree { Tree::EmptySequence => StackCipherText::EmptySequence(leaf()), Tree::EmptyMap => StackCipherText::EmptyMap(leaf()), Tree::Passthrough => StackCipherText::Passthrough(Box::new(FfiValue::Null)), + Tree::PassthroughBytes => StackCipherText::Passthrough(Box::new(FfiValue::Bytes( + Protected::new(b"{}".to_vec()), + ))), Tree::Sequence(items) => { StackCipherText::Sequence(items.into_iter().map(Tree::into_ciphertext).collect()) } @@ -192,7 +272,7 @@ impl Tree { /// key given twice, at any depth. fn is_clean(&self) -> bool { match self { - Tree::Passthrough => false, + Tree::Passthrough | Tree::PassthroughBytes => false, Tree::Sequence(items) => items.iter().all(Tree::is_clean), Tree::Map(entries) => { let unique = entries @@ -225,11 +305,14 @@ fn model_accepts(tree: &Tree, plan: &Plan) -> bool { rows.iter().all(|row| { plan.fields() .iter() - .filter(|field| field.has_ciphertext()) + .filter(|field| field.has_ciphertext() || field.target().is_some()) .all(|field| { - // The field exactly once in the row, and `"c"` exactly once - // in its output map: a second copy is how a stale ciphertext - // would be smuggled in beside the current one. + // The field exactly once in the row, and its one node + // exactly once in its output map: a second copy is how a + // stale ciphertext would be smuggled in beside the current + // one. A sealed field's node is `"c"` and must be clean; a + // target field's is `"eql"` and must be a passthrough of + // bytes — the EQL value, whose ciphertext is inside it. let mut named = row .iter() .filter(|(name, _)| name.as_str() == field.name()) @@ -237,14 +320,23 @@ fn model_accepts(tree: &Tree, plan: &Plan) -> bool { let (Some(Tree::Map(outputs)), None) = (named.next(), named.next()) else { return false; }; - let mut cs = outputs + let key = if field.target().is_some() { + Name::Eql + } else { + Name::C + }; + let mut nodes = outputs .iter() - .filter(|(name, _)| *name == Name::C) + .filter(|(name, _)| *name == key) .map(|(_, node)| node); - let (Some(ct), None) = (cs.next(), cs.next()) else { + let (Some(node), None) = (nodes.next(), nodes.next()) else { return false; }; - ct.is_clean() + if field.target().is_some() { + matches!(node, Tree::PassthroughBytes) + } else { + node.is_clean() + } }) }) } @@ -266,7 +358,7 @@ fuzz_target!(|case: Case| { let Case { plan: spec, record } = case; // The plan parser is fuzzed for panics only: its rules are a separate // model, and a plan that does not parse has no record to check. - let Ok(plan) = plan(spec.into_value()) else { + let Ok(plan) = plan_with(spec.into_value(), &Fixed) else { if trace { eprintln!("verdict: plan refused"); } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 04ceab49e..c8b0cf09c 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -26,6 +26,10 @@ //! is the same bytes (ADR-0007). The one step that stays dynamic is //! dispatching a value whose type is known only at run time to the typed //! term operation, which [`IndexSpec`]'s `Index` impls do. +//! * [`TargetResolver`] — the EQL types a build holds, installed by the host +//! that links them, so a plan field may name one as its target and the +//! lowering runs that type's own plan in the same request; [`NoTargets`] +//! is the build without them. //! * [`Value`] — an [`FfiValue`] as a plan field's plaintext, the type of //! every field lowered from data; [`TermBytes`] — the term such a field //! derives, as its frozen bytes. @@ -68,6 +72,7 @@ mod context; mod kind; pub mod record; +mod target; mod term; mod value; @@ -76,6 +81,7 @@ use std::fmt; pub use context::{borrowed, context}; pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; +pub use target::{NoTargets, TargetDescriptor, TargetError, TargetResolver}; pub use term::{term, Scalar, TermBytes}; pub use value::Value; /// vitaminc's language-neutral value tree — the runtime value every binding @@ -207,6 +213,15 @@ pub enum Error { #[error("internal invariant violated")] Internal, + /// A plan field names an EQL type as its target and the name, the + /// field's label or type, or the value does not fit: the build holds no + /// EQL types, no type has the name, the engine cannot produce it yet, + /// the plan is extended, or the value is of another kind. Decided when + /// the plan is built or the value is read, before any key is touched + /// — save [`TargetError::Other`], which is the resolver's own failure. + #[error(transparent)] + Target(#[from] TargetError), + /// Sealing, opening or deriving failed. #[error(transparent)] Cipher(#[from] crate::Error), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index ea7fcdfb6..14a44cb61 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -33,6 +33,31 @@ //! an integer, bytes, a one-element list) is refused as a plan a fields plan //! cannot express. //! +//! # A field that names an EQL type +//! +//! Instead of outputs, a field may name an EQL type as its **target**: +//! `{"context": [...], "target": "TextEq"}`. The two forms are exclusive — +//! a field with both is refused — and the lowering runs the named type's +//! own plan through the [`TargetResolver`] the host installed +//! ([`plan_with`], [`encrypt_with`], [`decrypt_with`], [`query`]), zipping +//! its [`Pending`] into the record's so the whole record is still one +//! ZeroKMS request. The bare entry points ([`plan`], [`encrypt`], +//! [`decrypt`]) run with [`NoTargets`], the resolver of a build without EQL +//! types, which refuses every target name when the plan is built: a plan +//! that parses there has no target field. +//! +//! A target field's label must be a column, `
/`, because +//! that is what an EQL value stores in its `i`: [`Plan::new_with`] refuses +//! a target field whose label has any other number of segments +//! ([`TargetError::Column`]) — [`FieldPlan::with_target`] itself takes any +//! label of two or more segments, as every field constructor does; the +//! column rule is the plan's — and an extended plan (a tenant part on every +//! label) has no column for it and is refused with +//! [`TargetError::Extended`] rather than silently dropping the extension. +//! The field's `"type"`, when declared, must be the kind the EQL type is +//! produced from ([`TargetError::Kind`]); undeclared, it is that kind, so +//! every value is checked against it as any typed field's is. +//! //! # What a field's `"type"` decides, and what it does not //! //! Every field lowered from data is a [`Value`]: its plaintext type is the @@ -68,10 +93,19 @@ //! //! A record is stored as `field → { output-key → node }`: `"c"` is the //! field's ciphertext, each term rides under its index key (`"eq"`, -//! `"match"`, `"ore"`, `"ope"`) as a passthrough byte node, and a passthrough -//! field rides under `"passthrough"`. A row written under one spelling is -//! read under the same spelling or not at all, so the keys are fixed here -//! and every binding agrees on them by construction. +//! `"match"`, `"ore"`, `"ope"`) as a passthrough byte node, a passthrough +//! field rides under `"passthrough"`, and a target field's EQL value rides +//! under [`EQL_KEY`] (`"eql"`) as a passthrough byte node holding the EQL +//! JSON. A row written under one spelling is read under the same spelling +//! or not at all, so the keys are fixed here and every binding agrees on +//! them by construction. +//! +//! The `"eql"` node is a passthrough on the wire and that is safe where a +//! passthrough under `"c"` is not: its bytes are not handed back as +//! plaintext. Opening them runs the EQL type's own decryption, which reads +//! the ciphertext *inside* the JSON, authenticates it under the field's +//! column and refuses a different stored identifier — so a forged `"eql"` +//! node opens to nothing, as a forged `"c"` leaf does. //! //! Under `"c"` a passthrough is refused in both directions, and that is //! load-bearing: opening a passthrough retrieves no key and opens no AEAD, @@ -82,8 +116,11 @@ //! that a round-trip invariant rather than data loss. use vitaminc_aead_value::{FfiValue, ValueKind}; +use vitaminc_protected::Controlled; -use super::{admits, utf8, Error, Scalar, Scope, TermBytes, Value}; +use super::{ + admits, utf8, Error, NoTargets, Scalar, Scope, TargetError, TargetResolver, TermBytes, Value, +}; use crate::plan::{FieldValues, FieldsBuilder, Opens, Runs}; use crate::target::{CallerContext, DeclaredContext, Decryption, Encrypted, IndexSpec}; use crate::{ @@ -91,6 +128,11 @@ use crate::{ StackCipherText, }; +/// The map key a target field's EQL value rides under in a stored record: +/// wire format, like the output keys (see the +/// [module docs](self#the-stored-record-is-wire-format)). +pub const EQL_KEY: &str = "eql"; + /// What a plan field asks for. /// /// The keys are wire format twice over: they are how a binding spells an @@ -146,13 +188,15 @@ impl Output { } } -/// The verb a field's outputs lower to: one of the plan builder's four. +/// The verb a field's outputs lower to: one of the plan builder's four, or +/// a target, which the resolver runs beside the lowered plan. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Verb { Encrypt, EncryptIndex, Index, Passthrough, + Target, } /// One field of a record plan: what to call it, what label to seal it @@ -171,6 +215,7 @@ pub struct FieldPlan { label: Label, extension: Vec>, outputs: Vec, + target: Option, field_type: Option, } @@ -220,6 +265,40 @@ impl FieldPlan { label, extension, outputs, + target: None, + field_type: None, + }) + } + + /// A field plan that names an EQL type as its target instead of + /// outputs: the type's own plan decides what is sealed and which terms + /// sit beside it. Whether the name is one this build can run is decided + /// when the plan is built ([`Plan::new_with`]), against the resolver. + /// + /// # Errors + /// + /// [`Error::Plan`] if `target` is empty, or if `context` is not a label + /// of at least two segments, optionally extended. + pub fn with_target( + name: impl Into, + context: NonEmpty>, + target: impl Into, + ) -> Result { + let target = target.into(); + if target.is_empty() { + return Err(Error::Plan); + } + let (label, extension) = split_context(context.get())?; + if label.segments().len() < 2 { + return Err(Error::Plan); + } + Ok(Self { + name: name.into(), + context, + label, + extension, + outputs: Vec::new(), + target: Some(target), field_type: None, }) } @@ -268,11 +347,17 @@ impl FieldPlan { self.label.segments().last().unwrap_or("") } - /// What the field produces. + /// What the field produces. Empty for a field that names a target: its + /// outputs are the EQL type's own. pub fn outputs(&self) -> &[Output] { &self.outputs } + /// The EQL type this field names as its target, if it does. + pub fn target(&self) -> Option<&str> { + self.target.as_deref() + } + /// The declared type of the field's values, if the plan declares one. /// A host with no types of its own reads this to know what a decrypted /// value is. @@ -292,7 +377,9 @@ impl FieldPlan { } fn verb(&self) -> Verb { - if self.outputs.contains(&Output::Passthrough) { + if self.target.is_some() { + Verb::Target + } else if self.outputs.contains(&Output::Passthrough) { Verb::Passthrough } else if self.has_ciphertext() { if self.indexes().is_empty() { @@ -336,6 +423,11 @@ impl FieldPlan { kind: self.field_type, } } + + /// Whether the field is run by the resolver rather than the lowered plan. + fn is_target(&self) -> bool { + self.target.is_some() + } } /// The text of a text part. @@ -394,7 +486,20 @@ pub struct Plan { impl Plan { /// A plan over `fields`, in the order given — which is the order of the - /// fields in every result. + /// fields in every result — for a build without EQL types: + /// [`new_with`](Self::new_with) under [`NoTargets`], so a field that + /// names a target is refused. + /// + /// # Errors + /// + /// As [`new_with`](Self::new_with). + pub fn new(fields: Vec) -> Result { + Self::new_with(fields, &NoTargets) + } + + /// A plan over `fields`, in the order given — which is the order of the + /// fields in every result — resolving each target field's name through + /// `resolver`. /// /// # Errors /// @@ -402,8 +507,13 @@ impl Plan { /// fields whose labels sit under different contexts or carry different /// extensions, or does not build as a fields plan: two sealed or indexed /// fields keyed under one identity, for instance, whose terms would be - /// interchangeable. - pub fn new(fields: Vec) -> Result { + /// interchangeable. [`Error::Target`] if a target field names a type the + /// resolver does not know or cannot produce, declares a `"type"` other + /// than the kind that type is produced from, or sits in an extended plan. + pub fn new_with( + mut fields: Vec, + resolver: &(impl TargetResolver + ?Sized), + ) -> Result { let Some(first) = fields.first() else { return Err(Error::Plan); }; @@ -416,6 +526,60 @@ impl Plan { if field.prefix()? != context || field.extension != extension { return Err(Error::Plan); } + // A target field is keyed under its identity like a sealed one; + // the builder checks that rule for the fields it lowers, so the + // target fields are checked against every field here. + if field.is_target() + && fields[..at] + .iter() + .any(|prior| prior.identity() == field.identity()) + { + return Err(Error::Plan); + } + if !field.is_target() + && fields[..at] + .iter() + .any(|prior| prior.is_target() && prior.identity() == field.identity()) + { + return Err(Error::Plan); + } + } + for field in fields.iter_mut().filter(|field| field.is_target()) { + let name = field.target.clone().unwrap_or_default(); + let descriptor = resolver.resolve(&name)?; + if !extension.is_empty() { + return Err(TargetError::Extended { + name: field.name.clone(), + label: field.label.to_string(), + } + .into()); + } + // An EQL value is stored under a table and a column, so the + // label is exactly two segments. Decided here, where the plan is + // built and se_plan_check reports it, not at the first value: a + // generator that asks the engine must be told before it writes + // the code. The resolver re-checks, since it is public API. + if field.label.segments().len() != 2 { + return Err(TargetError::Column { + name: field.name.clone(), + label: field.label.to_string(), + reason: "an EQL column is a two-segment label: table and column".to_owned(), + } + .into()); + } + match (field.field_type, descriptor.plaintext) { + (Some(declared), expected) if expected != Some(declared) => { + return Err(TargetError::Kind { + name: field.name.clone(), + target: name, + expected, + declared, + } + .into()); + } + (None, Some(kind)) => field.field_type = Some(kind), + _ => {} + } } let plan = Self { context, @@ -455,7 +619,7 @@ impl Plan { /// into. fn lower(&self) -> Result, crate::Error> { let mut builder = crate::Plan::context(self.context.clone()).fields::(); - for field in &self.fields { + for field in self.fields.iter().filter(|field| !field.is_target()) { builder = declare(builder, field); if field.identity() != field.name { builder = builder.identity(field.identity()); @@ -478,6 +642,11 @@ impl Plan { fn shape(&self) -> Vec { self.fields.iter().map(FieldPlan::shape).collect() } + + /// The fields that name a target, in plan order: the resolver's. + fn target_fields(&self) -> impl Iterator + '_ { + self.fields.iter().filter(|field| field.is_target()) + } } /// One field's verb, over a [`Value`]; its indexes are the [`IndexSpec`]s @@ -492,10 +661,24 @@ fn declare( Verb::EncryptIndex => builder.encrypt_index::(name, field.indexes()), Verb::Index => builder.index::(name, field.indexes()), Verb::Passthrough => builder.passthrough::(name), + // `lower` never hands a target field here: the resolver runs it. + Verb::Target => builder, } } -/// Read a record plan from a decoded value. +/// Read a record plan from a decoded value, for a build without EQL types: +/// [`plan_with`] under [`NoTargets`], so a field that names a `"target"` is +/// refused ([`TargetError::NoTargets`]). +/// +/// # Errors +/// +/// As [`plan_with`]. +pub fn plan(value: FfiValue) -> Result { + plan_with(value, &NoTargets) +} + +/// Read a record plan from a decoded value, resolving each target field's +/// name through `resolver`. /// /// The plan is an [`FfiValue::Object`]: /// @@ -503,6 +686,18 @@ fn declare( /// { : { "context": , "outputs": [ "c" | "passthrough" | , ... ], "type": }, ... } /// ``` /// +/// or, for a field that names an EQL type as its target: +/// +/// ```text +/// { : { "context": , "target": "", "type": }, ... } +/// ``` +/// +/// A field has `"outputs"` or `"target"`, never both. `"target"` is the +/// type's name as the resolver lists it (`"TextEq"`), resolved when the plan +/// is built so a name the build cannot run fails here and not at the first +/// value; its `"type"`, when given, must be the kind the type is produced +/// from. See the [module docs](self#a-field-that-names-an-eql-type). +/// /// `` is an index in its wire form, which is its key — `"eq"`, /// `"match"`, `"ore"` or `"ope"` — save for a match index with options other /// than the defaults, which is a one-entry object mapping `"match"` to them: @@ -548,12 +743,12 @@ fn declare( /// # Examples /// /// ``` -/// use stack_encrypt::dynamic::{record, FfiValue, Output}; +/// use stack_encrypt::dynamic::{record, FfiValue, NoTargets, Output}; /// use stack_encrypt::target::IndexSpec; /// /// // As a binding would decode it from its caller: seal `age` under the /// // label users/age and index it for equality. -/// let plan = record::plan(FfiValue::Object(vec![( +/// let plan = record::plan_with(FfiValue::Object(vec![( /// "age".to_string(), /// FfiValue::Object(vec![ /// ( @@ -571,7 +766,7 @@ fn declare( /// ]), /// ), /// ]), -/// )]))?; +/// )]), &NoTargets)?; /// /// assert_eq!(plan.fields().len(), 1); /// assert_eq!(plan.fields()[0].name(), "age"); @@ -587,22 +782,27 @@ fn declare( /// /// [`Error::Plan`] for a plan that is not an object of field specs, an /// empty plan, a field named twice, a spec with a key other than -/// `"context"`, `"outputs"` and `"type"` or with one given twice, missing -/// `"context"` or `"outputs"`, an output list that is not a list of outputs -/// (above), is empty, names an output key twice or names `"passthrough"` -/// beside another output, a `"type"` that is not a string naming a -/// [`ValueKind`], a type that does not admit one of the field's index -/// outputs, a context that is not a label of at least two segments -/// (optionally extended), fields under different contexts or extensions, -/// or a plan the builder refuses ([`Plan::new`]). [`Error::Context`] for a -/// `"context"` that is present but is not a context at all, or renders -/// empty. +/// `"context"`, `"outputs"`, `"target"` and `"type"` or with one given +/// twice, missing `"context"`, having neither `"outputs"` nor `"target"` +/// or having both, an output list that is not a list of outputs (above), +/// is empty, names an output key twice or names `"passthrough"` beside +/// another output, a `"target"` that is not a non-empty string, a `"type"` +/// that is not a string naming a [`ValueKind`], a type that does not admit +/// one of the field's index outputs, a context that is not a label of at +/// least two segments (optionally extended), fields under different +/// contexts or extensions, or a plan the builder refuses ([`Plan::new`]). +/// [`Error::Context`] for a `"context"` that is present but is not a +/// context at all, or renders empty. [`Error::Target`] for a target the +/// resolver refuses ([`Plan::new_with`]). /// /// The transport codec refuses duplicate object keys before a binding's /// value reaches here, but an [`FfiValue`] can be built with them directly /// and this is a public parser, so it refuses them itself rather than /// letting the last one win. -pub fn plan(value: FfiValue) -> Result { +pub fn plan_with( + value: FfiValue, + resolver: &(impl TargetResolver + ?Sized), +) -> Result { let FfiValue::Object(entries) = value else { return Err(Error::Plan); }; @@ -613,10 +813,17 @@ pub fn plan(value: FfiValue) -> Result { }; let mut context: Option>> = None; let mut outputs: Option> = None; + let mut target: Option = None; let mut field_type: Option = None; for (key, value) in spec { match key.as_str() { "context" if context.is_none() => context = Some(super::context(value)?), + "target" if target.is_none() => { + let FfiValue::String(s) = &value else { + return Err(Error::Plan); + }; + target = Some(utf8(s).ok_or(Error::Plan)?.to_owned()); + } "outputs" if outputs.is_none() => { let FfiValue::Array(items) = value else { return Err(Error::Plan); @@ -634,23 +841,25 @@ pub fn plan(value: FfiValue) -> Result { let name = utf8(s).ok_or(Error::Plan)?; field_type = Some(name.parse().map_err(|_| Error::Plan)?); } - // An unknown key, or one of the three given twice. + // An unknown key, or one of the four given twice. _ => return Err(Error::Plan), } } - let field = FieldPlan::new( - name, - context.ok_or(Error::Plan)?, - outputs.ok_or(Error::Plan)?, - )?; + let context = context.ok_or(Error::Plan)?; + // One form or the other: a field with both, or neither, is refused. + let field = match (outputs, target) { + (Some(outputs), None) => FieldPlan::new(name, context, outputs)?, + (None, Some(target)) => FieldPlan::with_target(name, context, target)?, + _ => return Err(Error::Plan), + }; fields.push(match field_type { Some(field_type) => field.with_type(field_type)?, None => field, }); } - // The whole-plan rules are `Plan::new`'s, so a parsed plan and a + // The whole-plan rules are `Plan::new_with`'s, so a parsed plan and a // hand-built one are refused alike. - Plan::new(fields) + Plan::new_with(fields, resolver) } /// Encrypt a record — or a batch of records — per a plan. @@ -727,32 +936,143 @@ pub fn plan(value: FfiValue) -> Result { /// [`Error::Source`] if the source does not fit the plan; [`Error::Term`] /// if a value has no term the plan asks for. Both are decided here, before /// the pending exists. A failure of the pending itself is the engine's. +/// +/// A plan with a target field needs the resolver that built it: +/// [`encrypt_with`]. This runs under [`NoTargets`], so such a plan is +/// refused ([`Error::Target`]). pub fn encrypt<'a, K: 'static>( cipher: &'a KeysetCipher<'_, K>, source: FfiValue, plan: &Plan, +) -> Result, Error> { + encrypt_with(cipher, source, plan, &NoTargets) +} + +/// [`encrypt`], running each target field's EQL type through `resolver`: +/// the type's own plan yields a [`Pending`] that is zipped into the +/// record's, so a record with target fields is still one ZeroKMS request. +/// A target field's value rides under [`EQL_KEY`] in the result. +/// +/// # Errors +/// +/// As [`encrypt`], plus [`Error::Target`] for a target value of another +/// kind than the type takes, or a name the resolver refuses. +pub fn encrypt_with<'a, K: 'static>( + cipher: &'a KeysetCipher<'_, K>, + source: FfiValue, + plan: &Plan, + resolver: &(impl TargetResolver + ?Sized), ) -> Result, Error> { let rows = source_rows(source, plan)?; let lowered = plan.lower::().map_err(|_| Error::Internal)?; let extend = plan.declared_context(); let shape = plan.shape(); + // Every target field of every row, in row-major order, run by the + // resolver; `all` merges their requests with the lowered plan's below. + let mut pendings = Vec::new(); + let mut targets = |row: Row| -> Result { + for (field, value) in plan.target_fields().zip(row.targets) { + let name = field.target().unwrap_or_default(); + pendings.push( + resolver + .encrypt(name, cipher, field.label(), value) + .map_err(|error| Error::Target(name_target(error, &field.name)))?, + ); + } + Ok(row.values) + }; Ok(match rows { - Rows::One(values) => { + Rows::One(row) => { + let values = targets(row)?; + let eql = Pending::all(cipher, pendings); Runs::::pending(&lowered, cipher, &values, None, extend) - .try_map(move |values| shape_record(values, &shape)) + .zip(eql) + .try_map(move |(values, eql)| shape_record(values, eql, &shape)) + } + Rows::Batch(rows) => { + let values = rows + .into_iter() + .map(&mut targets) + .collect::, Error>>()?; + let eql = Pending::all(cipher, pendings); + let per_row = plan.target_fields().count(); + Runs::<[FieldValues], K>::pending(&lowered, cipher, &values, None, extend) + .zip(eql) + .try_map(move |(rows, eql)| { + let mut eql = eql.into_iter(); + rows.into_iter() + .map(|values| { + let own: Vec> = eql.by_ref().take(per_row).collect(); + shape_record(values, own, &shape) + }) + .collect::, _>>() + .map(CipherText::Sequence) + }) } - Rows::Batch(rows) => Runs::<[FieldValues], K>::pending( - &lowered, cipher, &rows, None, extend, - ) - .try_map(move |rows| { - rows.into_iter() - .map(|values| shape_record(values, &shape)) - .collect::, _>>() - .map(CipherText::Sequence) - }), }) } +/// Derive the EQL query value of one target field for one plaintext: the +/// operand that matches stored values of the field, as JSON bytes, through +/// the type's own query plan. A query derives no data key, so the pending +/// settles without I/O; it is a [`Pending`] all the same, for one shape at +/// the call site. +/// +/// # Errors +/// +/// [`Error::Plan`] if the plan has no field `field` or it is not a target +/// field (a term of an indexed field is [`super::term`](super::term())); +/// [`Error::Source`] for a value of another kind than the field declares; +/// [`Error::Target`] for what the resolver refuses. +pub fn query<'a, K: 'static>( + cipher: &'a KeysetCipher<'_, K>, + plan: &Plan, + field: &str, + value: FfiValue, + resolver: &(impl TargetResolver + ?Sized), +) -> Result, K>, Error> { + let field = plan + .fields + .iter() + .find(|candidate| candidate.name == field) + .ok_or(Error::Plan)?; + let name = field.target().ok_or(Error::Plan)?; + check_field(&value, field)?; + resolver + .query(name, cipher, field.label(), value) + .map_err(|error| Error::Target(name_target(error, &field.name))) +} + +/// A resolver's refusal, with the field named where the resolver could not +/// name it: a resolver sees a type and a label, the lowering knows the +/// field. +fn name_target(error: TargetError, field: &str) -> TargetError { + match error { + TargetError::Column { label, reason, .. } => TargetError::Column { + name: field.to_owned(), + label, + reason, + }, + TargetError::Plaintext { + target, + expected, + found, + .. + } => TargetError::Plaintext { + name: field.to_owned(), + target, + expected, + found, + }, + TargetError::Stored { target, reason, .. } => TargetError::Stored { + name: field.to_owned(), + target, + reason, + }, + other => other, + } +} + /// Decrypt a record — or a batch — produced by [`encrypt`] under the same /// plan. /// @@ -775,31 +1095,94 @@ pub fn encrypt<'a, K: 'static>( /// before any key is retrieved), or a typed field that opens to a value of /// another kind than it declares ([`PlanError::FieldType`](crate::PlanError::FieldType) /// — the type tag is inside the AEAD envelope, so only opening can see it). +/// +/// A plan with a target field needs the resolver that built it: +/// [`decrypt_with`]. This runs under [`NoTargets`], so such a plan is +/// refused ([`Error::Target`]). pub fn decrypt<'a, K: 'static>( scope: Scope<'a, K>, record: StackCipherText, plan: &Plan, +) -> Result, Error> { + decrypt_with(scope, record, plan, &NoTargets) +} + +/// [`decrypt`], opening each target field's EQL value (the [`EQL_KEY`] +/// node) through `resolver`: the type's own decryption, under the field's +/// column, zipped into the record's pending and confined to the scope's +/// keyset as every other leaf is. +/// +/// # Errors +/// +/// As [`decrypt`], plus [`Error::Target`] for a stored value that is not +/// the type, or a name the resolver refuses. +pub fn decrypt_with<'a, K: 'static>( + scope: Scope<'a, K>, + record: StackCipherText, + plan: &Plan, + resolver: &(impl TargetResolver + ?Sized), ) -> Result, Error> { let rows = record_rows(record, plan)?; let lowered = plan.lower::().map_err(|_| Error::Internal)?; let extend = plan.declared_context(); let shape = plan.shape(); + let (cipher, keyset) = match &scope { + Scope::Client(cipher) => (*cipher, None), + Scope::Keyset(keyset) => (keyset.cipher(), Some(keyset.keyset_id())), + }; + // Confine a pending to the scope's keyset, as the lowered plan's is: + // a target value sealed under another keyset is refused before any key + // is retrieved. + let confine = move |pending: Pending<'a, FfiValue, K>| match keyset { + Some(keyset) => pending.scoped_to(keyset), + None => pending, + }; + let mut pendings = Vec::new(); + let mut targets = |row: StoredRow| -> Result { + for (field, stored) in plan.target_fields().zip(row.targets) { + let name = field.target().unwrap_or_default(); + pendings.push(confine( + resolver + .decrypt(name, cipher, field.label(), &stored) + .map_err(|error| Error::Target(name_target(error, &field.name)))?, + )); + } + Ok(row.values) + }; Ok(match rows { - Rows::One(values) => run( - scope, - Opens::::decryption(&lowered, values, None, extend), - ) - .try_map(move |values| open_record(values, &shape)), - Rows::Batch(rows) => run( - scope, - Opens::, K>::decryption(&lowered, rows, None, extend), - ) - .try_map(move |rows| { - rows.into_iter() - .map(|values| open_record(values, &shape)) - .collect::, _>>() - .map(FfiValue::Array) - }), + Rows::One(row) => { + let values = targets(row)?; + let eql = Pending::all(cipher, pendings); + run( + scope, + Opens::::decryption(&lowered, values, None, extend), + ) + .zip(eql) + .try_map(move |(values, eql)| open_record(values, eql, &shape)) + } + Rows::Batch(rows) => { + let values = rows + .into_iter() + .map(&mut targets) + .collect::, Error>>()?; + let eql = Pending::all(cipher, pendings); + let per_row = plan.target_fields().count(); + run( + scope, + Opens::, K>::decryption(&lowered, values, None, extend), + ) + .zip(eql) + .try_map(move |(rows, eql)| { + let mut eql = eql.into_iter(); + rows.into_iter() + .map(|values| { + let own: Vec = eql.by_ref().take(per_row).collect(); + open_record(values, own, &shape) + }) + .collect::, _>>() + .map(FfiValue::Array) + }) + } }) } @@ -834,11 +1217,11 @@ fn run<'a, K: 'static, T: 'static>( pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { let rows = source_rows(source, plan)?; let lowered = plan.lower::<()>().map_err(|_| Error::Internal)?; - let check = |values: &FieldValues| { - Runs::::check(&lowered, values, None).map_err(|_| Error::Source) + let check = |row: &Row| { + Runs::::check(&lowered, &row.values, None).map_err(|_| Error::Source) }; match &rows { - Rows::One(values) => check(values), + Rows::One(row) => check(row), Rows::Batch(rows) => rows.iter().try_for_each(check), } } @@ -856,11 +1239,11 @@ pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { pub fn check_record(record: StackCipherText, plan: &Plan) -> Result<(), Error> { let rows = record_rows(record, plan)?; let lowered = plan.lower::<()>().map_err(|_| Error::Internal)?; - let check = |values: &FieldValues| { - Opens::::check(&lowered, values, None).map_err(|_| Error::Record) + let check = |row: &StoredRow| { + Opens::::check(&lowered, &row.values, None).map_err(|_| Error::Record) }; match &rows { - Rows::One(values) => check(values), + Rows::One(row) => check(row), Rows::Batch(rows) => rows.iter().try_for_each(check), } } @@ -930,9 +1313,23 @@ impl RecordTree for StackCipherText { /// The rows of a call, as the engine's records: one, or a batch, so the /// result takes the shape the input had. -enum Rows { - One(FieldValues), - Batch(Vec), +enum Rows { + One(R), + Batch(Vec), +} + +/// One source row: the lowered plan's values, and each target field's +/// value in plan order, for the resolver. +struct Row { + values: FieldValues, + targets: Vec, +} + +/// One stored row: the lowered plan's ciphertexts, and each target field's +/// EQL bytes in plan order, for the resolver. +struct StoredRow { + values: FieldValues, + targets: Vec>, } /// Take the one entry named `name` out of a row, whatever order the row had @@ -1001,7 +1398,7 @@ fn check_tree(tree: &T) -> Result<(), Error> { /// the plan does not name, and each value fits its field ([`check_field`]). /// Each field is moved out of the source into its slot at the type its kind /// lowers to; nothing else is copied. -fn source_rows(source: FfiValue, plan: &Plan) -> Result { +fn source_rows(source: FfiValue, plan: &Plan) -> Result, Error> { match source { FfiValue::Object(row) => Ok(Rows::One(source_row(row, plan)?)), FfiValue::Array(items) => Ok(Rows::Batch( @@ -1017,17 +1414,22 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result { } } -fn source_row(mut row: Vec<(String, FfiValue)>, plan: &Plan) -> Result { +fn source_row(mut row: Vec<(String, FfiValue)>, plan: &Plan) -> Result { if row.len() != plan.fields.len() { return Err(Error::Source); } let mut values = FieldValues::new(); + let mut targets = Vec::new(); for field in &plan.fields { let (_, value) = take(&mut row, &field.name).ok_or(Error::Source)?; check_field(&value, field)?; - let _ = values.insert(&field.name, Value::new(value)); + if field.is_target() { + targets.push(value); + } else { + let _ = values.insert(&field.name, Value::new(value)); + } } - Ok(values) + Ok(Row { values, targets }) } /// A source value against its plan field: a typed field needs a value of @@ -1041,6 +1443,11 @@ fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { return Err(Error::Source); } } + // A target field's value is sealed by the type's own plan, which + // refuses a passthrough as any ciphertext does; the same walk here. + if field.is_target() { + check_tree(value)?; + } for output in &field.outputs { match output { Output::Ciphertext => check_tree(value)?, @@ -1089,11 +1496,25 @@ fn term_node(term: TermBytes) -> StackCipherText { /// order, its output map. fn shape_record( mut values: FieldValues, + eql: Vec>, shape: &[FieldShape], ) -> Result { let mut fields = Vec::with_capacity(shape.len()); + let mut eql = eql.into_iter(); for field in shape { let outputs = match field.verb { + Verb::Target => { + // The resolver answered one value per target field, in plan + // order; running short is the engine answering with a + // different shape than it was asked. + let value = eql.next().ok_or(crate::Error::ResponseShape)?; + vec![( + EQL_KEY.to_string(), + CipherText::Passthrough(Box::new(FfiValue::Bytes( + vitaminc_protected::Protected::new(value), + )) as BoxedPassthrough), + )] + } Verb::Encrypt => { let ciphertext: StackCipherText = slot(&mut values, &field.name)?; vec![("c".to_string(), ciphertext)] @@ -1145,10 +1566,19 @@ fn take_leaf(values: &mut FieldValues, name: &str) -> Result Result { +fn open_record( + mut values: FieldValues, + eql: Vec, + shape: &[FieldShape], +) -> Result { let mut fields = Vec::with_capacity(shape.len()); + let mut eql = eql.into_iter(); for field in shape.iter().filter(|field| field.verb != Verb::Index) { - let value = take_leaf(&mut values, &field.name)?; + let value = if field.verb == Verb::Target { + eql.next().ok_or(crate::Error::ResponseShape)? + } else { + take_leaf(&mut values, &field.name)? + }; if let Some(kind) = field.kind { if !kind.holds(&value) { return Err(crate::PlanError::FieldType { @@ -1174,7 +1604,7 @@ fn open_record(mut values: FieldValues, shape: &[FieldShape]) -> Result Result { +fn record_rows(tree: StackCipherText, plan: &Plan) -> Result, Error> { match tree { CipherText::Map(row) => Ok(Rows::One(record_row(row, plan)?)), CipherText::Sequence(items) => Ok(Rows::Batch( @@ -1190,14 +1620,28 @@ fn record_rows(tree: StackCipherText, plan: &Plan) -> Result { } } -fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result { +fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result { let mut values = FieldValues::new(); + let mut targets = Vec::new(); for field in plan.fields.iter().filter(|field| field.opens()) { let (_, node) = take(&mut row, &field.name).ok_or(Error::Record)?; let CipherText::Map(mut outputs) = node else { return Err(Error::Record); }; match field.verb() { + Verb::Target => { + // The EQL value: a passthrough carrying bytes, exactly once. + // What it opens to is the type's own decryption's to decide. + let (_, node) = take(&mut outputs, EQL_KEY).ok_or(Error::Record)?; + let CipherText::Passthrough(payload) = node else { + return Err(Error::Record); + }; + let value = *payload.downcast::().map_err(|_| Error::Record)?; + let FfiValue::Bytes(bytes) = value else { + return Err(Error::Record); + }; + targets.push(bytes.risky_unwrap()); + } Verb::Passthrough => { let (_, node) = take(&mut outputs, "passthrough").ok_or(Error::Record)?; let CipherText::Passthrough(payload) = node else { @@ -1216,7 +1660,7 @@ fn record_row(mut row: Vec<(String, StackCipherText)>, plan: &Plan) -> Result String { + bytes.iter().map(|b| format!("{b:02x}")).collect() + } + + fn unhex(text: &str) -> Option> { + (0..text.len()) + .step_by(2) + .map(|at| u8::from_str_radix(text.get(at..at + 2)?, 16).ok()) + .collect() + } + + fn text_of_value(name: &str, value: FfiValue) -> Result { + match value { + FfiValue::String(text) => Ok(utf8(&text).unwrap_or_default().to_owned()), + other => Err(TargetError::Plaintext { + name: String::new(), + target: name.to_owned(), + expected: Some(ValueKind::String), + found: other.kind(), + }), + } + } + + fn column(name: &str, label: &Label) -> Result, TargetError> { + if label.segments().len() != 2 { + return Err(TargetError::Column { + name: String::new(), + label: label.to_string(), + reason: format!("{name} is stored under a table and a column"), + }); + } + Ok(NonEmpty::from(label.clone())) + } + + impl TargetResolver for FakeEql { + fn targets(&self) -> Vec { + vec![ + TargetDescriptor::new( + TEXT_EQ, + "text", + "Eq", + Some(ValueKind::String), + "public.eql_v3_text_eq", + vec!["eq".to_string()], + Some("TextEqQuery".to_string()), + Some("eql_v3.query_text_eq".to_string()), + true, + None, + ), + TargetDescriptor::new( + "TextOrdOre", + "text", + "OrdOre", + Some(ValueKind::String), + "public.eql_v3_text_ord_ore", + vec!["eq".to_string(), "ore".to_string()], + Some("TextOrdOreQuery".to_string()), + Some("eql_v3.query_text_ord_ore".to_string()), + false, + Some("block ORE is not CLLW ORE".to_string()), + ), + ] + } + + fn encrypt<'a, K: 'static>( + &self, + name: &str, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + let _ = self.resolve(name)?; + let text = text_of_value(name, plaintext)?; + let column = column(name, label)?; + let stored_label = label.to_string(); + Ok(keyset + .encrypt_as::(&text, AeadContext::from(column)) + .try_map(move |sealed| { + let CipherText::Single(leaf) = sealed else { + return Err(crate::Error::UnsupportedShape); + }; + serde_json::to_vec(&serde_json::json!({ + "v": 3, + "i": stored_label, + "c": hex(&leaf.to_bytes()), + })) + .map_err(|e| crate::Error::Other(Box::new(e))) + })) + } + + fn decrypt<'a, K: 'static>( + &self, + name: &str, + cipher: &'a StackCipher, + label: &Label, + stored: &[u8], + ) -> Result, TargetError> { + let _ = self.resolve(name)?; + let column = column(name, label)?; + let stored: serde_json::Value = + serde_json::from_slice(stored).map_err(|e| TargetError::Stored { + name: String::new(), + target: name.to_owned(), + reason: e.to_string(), + })?; + let leaf = stored["c"] + .as_str() + .and_then(unhex) + .and_then(|bytes| SealedValue::from_bytes(&bytes).ok()) + .ok_or_else(|| TargetError::Stored { + name: String::new(), + target: name.to_owned(), + reason: "no ciphertext".to_owned(), + })?; + if stored["i"].as_str() != Some(&label.to_string()) { + return Ok(Pending::failed( + cipher, + crate::Error::Other("stored under another column".into()), + )); + } + let opening: Decryption = + crate::target::open(CipherText::Single(leaf), AeadContext::from(column)); + Ok(cipher + .run_decryption(opening) + .map(|text| FfiValue::String(text.into()))) + } + + fn query<'a, K: 'static>( + &self, + name: &str, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + let _ = self.resolve(name)?; + let text = text_of_value(name, plaintext)?; + let column = column(name, label)?; + let stored_label = label.to_string(); + Ok(keyset + .encrypt_as::(&text, CallerContext::from(column)) + .try_map(move |term| { + serde_json::to_vec(&serde_json::json!({ + "v": 3, + "i": stored_label, + "hm": hex(term.as_bytes()), + })) + .map_err(|e| crate::Error::Other(Box::new(e))) + })) + } + } + + fn target_spec(context: FfiValue, target: &str) -> FfiValue { + obj(vec![("context", context), ("target", s(target))]) + } + + /// `age` sealed and indexed by the lowered plan, `email` a target. + fn mixed_plan_value() -> FfiValue { + obj(vec![ + ("age", spec(label("age"), &["c", "eq"])), + ("email", target_spec(label("email"), TEXT_EQ)), + ]) + } + + fn mixed_plan() -> Plan { + plan_with(mixed_plan_value(), &FakeEql).expect("a plan with a target parses") + } + + fn mixed_row(age: u32, email: &str) -> FfiValue { + obj(vec![("age", FfiValue::UInt32(age)), ("email", s(email))]) + } + + fn eql_json(record: &mut Vec<(String, StackCipherText)>, field: &str) -> serde_json::Value { + let CipherText::Map(mut outputs) = node(record, field) else { + panic!("{field} is a map of outputs"); + }; + assert_eq!(keys(&outputs), [EQL_KEY], "{field}: one node, under eql"); + let CipherText::Passthrough(payload) = node(&mut outputs, EQL_KEY) else { + panic!("the eql node is a passthrough"); + }; + let FfiValue::Bytes(bytes) = *payload.downcast::().unwrap() else { + panic!("the eql node carries bytes"); + }; + serde_json::from_slice(bytes.risky_ref()).expect("the eql node is JSON") + } + + fn target_error(error: Error) -> TargetError { + match error { + Error::Target(error) => error, + other => panic!("expected a target refusal, got {other:?}"), + } + } + + /// The refusal a call returned; a `Pending` has no `Debug`, so this + /// stands in for `unwrap_err`. + fn refused(result: Result) -> Error { + match result { + Err(error) => error, + Ok(_) => panic!("the call accepted what it should have refused"), + } + } + + async fn seal_mixed( + keyset: &KeysetCipher<'_, Counting>, + plan: &Plan, + ) -> Vec<(String, StackCipherText)> { + map(encrypt_with(keyset, mixed_row(1, "a@x"), plan, &FakeEql) + .expect("fits") + .await + .expect("seals")) + } + + #[tokio::test] + async fn is_sealed_by_the_resolver_in_the_records_one_request() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + assert_eq!(plan.fields()[1].target(), Some(TEXT_EQ)); + assert!( + plan.fields()[1].outputs().is_empty(), + "a target's outputs are the type's" + ); + assert_eq!( + plan.fields()[1].field_type(), + Some(ValueKind::String), + "an undeclared type is the target's plaintext kind" + ); + + let sealed = encrypt_with(&keyset, mixed_row(34, "a@x"), &plan, &FakeEql) + .expect("the row fits") + .await + .expect("seals"); + assert_eq!( + generates(&cipher), + 1, + "the target leaf minted in the record's one request" + ); + let mut record = map(sealed); + assert_eq!(keys(&record), ["age", "email"], "plan order"); + let eql = eql_json(&mut record, "email"); + assert_eq!(eql["v"], 3); + assert_eq!( + eql["i"], "users/email", + "the field's label is the stored column" + ); + assert!(eql["c"].is_string()); + + // `node` took the inspected entry out; a fresh record opens. + let record = seal_mixed(&keyset, &plan).await; + let opened = decrypt_with( + Scope::Client(&cipher), + CipherText::Map(record), + &plan, + &FakeEql, + ) + .expect("the record fits") + .await + .expect("opens"); + assert_eq!( + retrieves(&cipher), + 1, + "the target leaf retrieved in the record's one request" + ); + let fields = object(opened); + assert_eq!(keys(&fields), ["age", "email"]); + assert_eq!(u32_of(&fields[0].1), 1, "seal_mixed's age"); + assert_eq!(text_of(&fields[1].1), "a@x"); + + // Through a keyset scope, the target's pending is confined to it + // like every other leaf's. + let record = seal_mixed(&keyset, &plan).await; + let opened = decrypt_with( + Scope::Keyset(cipher.default_keyset()), + CipherText::Map(record), + &plan, + &FakeEql, + ) + .expect("the record fits") + .await + .expect("opens under its own keyset"); + assert_eq!(text_of(&object(opened)[1].1), "a@x"); + } + + #[tokio::test] + async fn a_batch_keeps_every_rows_target_in_order() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + let rows = FfiValue::Array(vec![ + mixed_row(1, "one"), + mixed_row(2, "two"), + mixed_row(3, "three"), + ]); + let sealed = encrypt_with(&keyset, rows, &plan, &FakeEql) + .expect("the rows fit") + .await + .expect("seals"); + assert_eq!( + generates(&cipher), + 1, + "three rows, two leaves each, one request" + ); + let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, &FakeEql) + .expect("the batch fits") + .await + .expect("opens"); + assert_eq!(retrieves(&cipher), 1); + let rows = array(opened); + let emails: Vec = rows + .into_iter() + .map(|row| text_of(&object(row)[1].1)) + .collect(); + assert_eq!( + emails, + ["one", "two", "three"], + "each row's target is its own" + ); + } + + #[tokio::test] + async fn a_plan_of_targets_alone_runs_with_no_lowered_field() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .expect("parses"); + let sealed = encrypt_with(&keyset, obj(vec![("email", s("a@x"))]), &plan, &FakeEql) + .expect("fits") + .await + .expect("seals"); + assert_eq!(generates(&cipher), 1); + let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, &FakeEql) + .expect("fits") + .await + .expect("opens"); + assert_eq!(text_of(&object(opened)[0].1), "a@x"); + } + + #[test] + fn is_refused_when_the_plan_is_built_without_a_resolver() { + // The bare entry points are the build without EQL types. + let error = target_error(plan(mixed_plan_value()).unwrap_err()); + assert!( + matches!(&error, TargetError::NoTargets { name } if name == TEXT_EQ), + "{error}" + ); + let fields = vec![ + FieldPlan::new( + "age", + context(label("age")).unwrap(), + vec![Output::Ciphertext], + ) + .unwrap(), + FieldPlan::with_target("email", context(label("email")).unwrap(), TEXT_EQ).unwrap(), + ]; + assert!(matches!( + Plan::new(fields).unwrap_err(), + Error::Target(TargetError::NoTargets { .. }) + )); + } + + #[tokio::test] + async fn a_plan_built_with_a_resolver_is_refused_by_the_bare_entry_points() { + // Fail closed: a plan from the build with EQL types handed to the + // build without them is refused, never half-sealed. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + let error = target_error(refused(encrypt(&keyset, mixed_row(1, "a@x"), &plan))); + assert!(matches!(error, TargetError::NoTargets { .. }), "{error}"); + assert_eq!(generates(&cipher), 0, "nothing minted"); + let sealed = CipherText::Map(seal_mixed(&keyset, &plan).await); + let error = target_error(refused(decrypt(Scope::Client(&cipher), sealed, &plan))); + assert!(matches!(error, TargetError::NoTargets { .. }), "{error}"); + assert_eq!(retrieves(&cipher), 0, "nothing retrieved"); + } + + #[test] + fn a_name_the_build_cannot_run_is_refused_when_the_plan_is_built() { + let refused = |target: &str| { + target_error( + plan_with( + obj(vec![("email", target_spec(label("email"), target))]), + &FakeEql, + ) + .unwrap_err(), + ) + }; + assert!(matches!(refused("Nope"), TargetError::Unknown { name } if name == "Nope")); + assert!(matches!( + refused("TextOrdOre"), + TargetError::Unproducible { name, reason } if name == "TextOrdOre" && reason.contains("CLLW") + )); + assert!( + matches!(refused("texteq"), TargetError::Unknown { .. }), + "exact names" + ); + } + + /// The constructor's one bound on the label is "at least two + /// segments", the same as every field constructor's: a longer label + /// is still a label. The column rule — exactly two — is + /// `Plan::new_with`'s, where the plan is built (the test after this + /// one). Pinned from both sides so the constructor's bound cannot + /// drift to "exactly two" or flip: one segment refused (it is no + /// label), two accepted, and three accepted intact. + #[test] + fn with_target_takes_any_label_of_two_or_more_segments() { + let ctx = |segments: &[&str]| context(strings(segments)).expect("a context value"); + assert!( + matches!( + FieldPlan::with_target("email", ctx(&["users"]), TEXT_EQ), + Err(Error::Plan) + ), + "one segment is not a label" + ); + let two = FieldPlan::with_target("email", ctx(&["users", "email"]), TEXT_EQ) + .expect("two segments: table and column"); + assert_eq!(two.label().to_string(), "users/email"); + let three = + FieldPlan::with_target("email", ctx(&["tenant", "users", "email"]), TEXT_EQ) + .expect("three segments are a label; the column rule is the resolver's"); + assert_eq!(three.label().segments().count(), 3); + assert_eq!(three.identity(), "email"); + assert_eq!(three.target(), Some(TEXT_EQ)); + } + + /// `context=app/users` with `email,encrypt_into=TextEq` in Go gives + /// the label `app/users/email`: no column for it. Refused when the + /// plan is built, so `se_plan_check` tells the generator before it + /// writes the code, and no value ever reaches the resolver. + #[tokio::test] + async fn a_target_label_that_is_not_table_and_column_is_refused_when_the_plan_is_built() { + let cipher = cipher().await; + let value = obj(vec![( + "email", + target_spec(strings(&["app", "users", "email"]), TEXT_EQ), + )]); + let error = target_error(plan_with(value, &FakeEql).unwrap_err()); + assert!( + matches!(&error, TargetError::Column { name, label, .. } if name == "email" && label == "app/users/email"), + "{error}" + ); + assert_eq!( + error.to_string(), + "email: the label app/users/email is not an EQL column: an EQL column is a \ + two-segment label: table and column" + ); + // A sealed field under the same three-segment label is fine: the + // rule is the EQL column's, not the plan's. + let value = obj(vec![( + "email", + spec(strings(&["app", "users", "email"]), &["c"]), + )]); + assert!(plan_with(value, &FakeEql).is_ok()); + assert_eq!(generates(&cipher), 0, "nothing minted"); + } + + /// The resolver opens a target value through the client, which opens + /// every keyset's values; `confine` is what holds it to the scope's + /// keyset. A plan of one target field, so no lowered leaf can cause + /// the refusal instead: this fails if `confine` stops calling + /// `scoped_to`, and one tenant's cipher opens another's column. + #[tokio::test] + async fn a_keyset_scope_refuses_a_target_value_from_another_keyset() { + let cipher = cipher().await; + let named = |n: &str| IdentifiedBy::Name(n.to_string().into()); + let acme = cipher.keyset(named("acme")).await.expect("acme"); + let globex = cipher.keyset(named("globex")).await.expect("globex"); + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .unwrap(); + let sealed = encrypt_with(&acme, obj(vec![("email", s("a@x"))]), &plan, &FakeEql) + .unwrap() + .await + .unwrap(); + let result = decrypt_with(Scope::Keyset(globex), sealed, &plan, &FakeEql) + .expect("the record fits") + .await; + match result { + Err(crate::Error::ForeignKeyset { .. }) => {} + Err(other) => panic!("refused, but not as a foreign keyset: {other}"), + Ok(_) => panic!("globex opened acme's target value"), + } + assert_eq!( + retrieves(&cipher), + 0, + "refused before any key was retrieved" + ); + // acme's own scope, and the client, still open it. + let sealed = encrypt_with(&acme, obj(vec![("email", s("a@x"))]), &plan, &FakeEql) + .unwrap() + .await + .unwrap(); + let opened = decrypt_with(Scope::Keyset(acme), sealed, &plan, &FakeEql) + .unwrap() + .await + .expect("its own keyset opens it"); + assert_eq!(text_of(&object(opened)[0].1), "a@x"); + } + + /// The node under `"eql"` must be a passthrough: a ciphertext leaf + /// there is a record the plan did not produce, refused before any + /// key is retrieved. (The "misplaced" case in the test below puts + /// the leaf under `"c"`, where `take` of `"eql"` fails first; this + /// one reaches the node-shape refusal itself.) + #[tokio::test] + async fn a_non_passthrough_node_under_eql_is_refused() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + let mut row = seal_mixed(&keyset, &plan).await; + row.retain(|(k, _)| k != "email"); + row.push(( + "email".to_string(), + CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), + )); + assert!(matches!( + check_record(CipherText::Map(row), &plan), + Err(Error::Record) + )); + let mut row = seal_mixed(&keyset, &plan).await; + row.retain(|(k, _)| k != "email"); + row.push(( + "email".to_string(), + CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), + )); + let error = refused(decrypt_with( + Scope::Client(&cipher), + CipherText::Map(row), + &plan, + &FakeEql, + )); + assert!(matches!(error, Error::Record), "{error:?}"); + assert_eq!(retrieves(&cipher), 0); + } + + /// The adapters trust the resolver to answer one value per target + /// field; fewer is the engine's shape disagreeing with the plan's, + /// reported as `ResponseShape` (a binding's internal status), never + /// as a refusal the caller is told to fix. + #[test] + fn a_target_slot_the_resolver_did_not_answer_is_a_response_shape_error() { + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .unwrap(); + let shape = plan.shape(); + assert!( + matches!( + shape_record(FieldValues::new(), Vec::new(), &shape), + Err(crate::Error::ResponseShape) + ), + "a missing target value on the encrypt side" + ); + assert!( + matches!( + open_record(FieldValues::new(), Vec::new(), &shape), + Err(crate::Error::ResponseShape) + ), + "a missing target value on the decrypt side" + ); + } + + #[test] + fn the_target_and_output_forms_are_exclusive() { + let both = obj(vec![ + ("context", label("email")), + ("outputs", strings(&["c"])), + ("target", s(TEXT_EQ)), + ]); + assert!(matches!( + plan_with(obj(vec![("email", both)]), &FakeEql), + Err(Error::Plan) + )); + let neither = obj(vec![("context", label("email"))]); + assert!(matches!( + plan_with(obj(vec![("email", neither)]), &FakeEql), + Err(Error::Plan) + )); + let not_text = obj(vec![ + ("context", label("email")), + ("target", FfiValue::UInt32(1)), + ]); + assert!(matches!( + plan_with(obj(vec![("email", not_text)]), &FakeEql), + Err(Error::Plan) + )); + let empty = obj(vec![("context", label("email")), ("target", s(""))]); + assert!(matches!( + plan_with(obj(vec![("email", empty)]), &FakeEql), + Err(Error::Plan) + )); + let twice = obj(vec![ + ("context", label("email")), + ("target", s(TEXT_EQ)), + ("target", s(TEXT_EQ)), + ]); + assert!(matches!( + plan_with(obj(vec![("email", twice)]), &FakeEql), + Err(Error::Plan) + )); + } + + #[tokio::test] + async fn the_fields_type_is_the_targets_plaintext_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + // Declared and agreeing: fine. + let declared = obj(vec![ + ("context", label("email")), + ("target", s(TEXT_EQ)), + ("type", s("string")), + ]); + let plan = plan_with(obj(vec![("email", declared)]), &FakeEql).expect("agrees"); + assert_eq!(plan.fields()[0].field_type(), Some(ValueKind::String)); + // Declared and disagreeing: refused when the plan is built. + let other = obj(vec![ + ("context", label("email")), + ("target", s(TEXT_EQ)), + ("type", s("uint64")), + ]); + let error = target_error(plan_with(obj(vec![("email", other)]), &FakeEql).unwrap_err()); + assert!( + matches!(&error, TargetError::Kind { name, target, expected: Some(ValueKind::String), declared: ValueKind::UInt64 } if name == "email" && target == TEXT_EQ), + "{error}" + ); + // A value of another kind is refused before the resolver runs. + let wrong = obj(vec![("email", FfiValue::UInt32(7))]); + assert!(matches!(check_source(wrong, &plan), Err(Error::Source))); + let wrong = obj(vec![("email", FfiValue::UInt32(7))]); + assert!(matches!( + refused(encrypt_with(&keyset, wrong, &plan, &FakeEql)), + Error::Source + )); + // A passthrough is refused as it is for any sealed field. + let forged = obj(vec![("email", FfiValue::Passthrough(Box::new(s("a@x"))))]); + assert!(matches!( + refused(encrypt_with(&keyset, forged, &plan, &FakeEql)), + Error::Source + )); + assert_eq!(generates(&cipher), 0); + } + + #[test] + fn an_extended_plan_refuses_a_target_field_rather_than_dropping_the_extension() { + let extended = |field: &str| FfiValue::Array(vec![label(field), FfiValue::UInt32(7)]); + let value = obj(vec![ + ("age", spec(extended("age"), &["c"])), + ("email", target_spec(extended("email"), TEXT_EQ)), + ]); + let error = target_error(plan_with(value, &FakeEql).unwrap_err()); + assert!( + matches!(&error, TargetError::Extended { name, label } if name == "email" && label == "users/email"), + "{error}" + ); + // The same plan without the target field extends as before. + let value = obj(vec![("age", spec(extended("age"), &["c"]))]); + assert!(plan_with(value, &FakeEql).is_ok()); + } + + #[test] + fn a_target_field_is_keyed_under_its_identity_like_a_sealed_one() { + // Two fields under one label, one of them a target: the terms + // and the EQL value would be interchangeable, so refused as two + // sealed fields under one identity are. + let fields = vec![ + FieldPlan::new( + "mail", + context(label("email")).unwrap(), + vec![Output::Ciphertext], + ) + .unwrap(), + FieldPlan::with_target("email", context(label("email")).unwrap(), TEXT_EQ).unwrap(), + ]; + assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + let fields = vec![ + FieldPlan::with_target("email", context(label("email")).unwrap(), TEXT_EQ).unwrap(), + FieldPlan::new( + "mail", + context(label("email")).unwrap(), + vec![Output::Ciphertext], + ) + .unwrap(), + ]; + assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + let fields = vec![ + FieldPlan::with_target("a", context(label("email")).unwrap(), TEXT_EQ).unwrap(), + FieldPlan::with_target("b", context(label("email")).unwrap(), TEXT_EQ).unwrap(), + ]; + assert!(matches!(Plan::new_with(fields, &FakeEql), Err(Error::Plan))); + } + + #[tokio::test] + async fn a_stored_eql_node_is_passthrough_bytes_exactly_once() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + // A fresh record each time: a ciphertext tree is not `Clone`. + let with_email = |mut row: Vec<(String, StackCipherText)>, node: StackCipherText| { + row.retain(|(k, _)| k != "email"); + row.push(("email".to_string(), node)); + CipherText::Map(row) + }; + let bytes_node = |bytes: &[u8]| { + CipherText::Map(vec![( + EQL_KEY.to_string(), + CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( + bytes.to_vec(), + ))) as BoxedPassthrough), + )]) + }; + // A ciphertext leaf where the EQL value should be. + let misplaced = CipherText::Map(vec![("c".to_string(), forged(s("x")))]); + let record = with_email(seal_mixed(&keyset, &plan).await, misplaced); + assert!(matches!(check_record(record, &plan), Err(Error::Record))); + // The node twice. + let mut row = seal_mixed(&keyset, &plan).await; + let CipherText::Map(mut outputs) = node(&mut row, "email") else { + panic!("a map") + }; + let CipherText::Map(again) = node(&mut seal_mixed(&keyset, &plan).await, "email") + else { + panic!("a map") + }; + outputs.extend(again); + row.push(("email".to_string(), CipherText::Map(outputs))); + assert!(matches!( + check_record(CipherText::Map(row), &plan), + Err(Error::Record) + )); + // A payload that is not bytes. + let null = CipherText::Map(vec![( + EQL_KEY.to_string(), + CipherText::Passthrough(Box::new(FfiValue::Null) as BoxedPassthrough), + )]); + let record = with_email(seal_mixed(&keyset, &plan).await, null); + assert!(matches!(check_record(record, &plan), Err(Error::Record))); + // Bytes that are not the type: the shape fits, and the resolver + // refuses them before any key is retrieved. + let record = with_email(seal_mixed(&keyset, &plan).await, bytes_node(b"not json")); + assert!(check_record(record, &plan).is_ok(), "the shape fits"); + let retrieved = retrieves(&cipher); + let record = with_email(seal_mixed(&keyset, &plan).await, bytes_node(b"not json")); + let error = target_error(refused(decrypt_with( + Scope::Client(&cipher), + record, + &plan, + &FakeEql, + ))); + assert!( + matches!(&error, TargetError::Stored { name, target, .. } if name == "email" && target == TEXT_EQ), + "{error}" + ); + assert_eq!(retrieves(&cipher), retrieved, "nothing retrieved"); + // An untouched record still opens. + let record = CipherText::Map(seal_mixed(&keyset, &plan).await); + let opened = decrypt_with(Scope::Client(&cipher), record, &plan, &FakeEql) + .unwrap() + .await + .unwrap(); + assert_eq!(text_of(&object(opened)[1].1), "a@x"); + } + + #[tokio::test] + async fn a_query_runs_the_targets_query_through_the_resolver() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + let probe = query(&keyset, &plan, "email", s("a@x"), &FakeEql) + .expect("a target field") + .await + .expect("derives"); + let probe: serde_json::Value = serde_json::from_slice(&probe).unwrap(); + assert_eq!(probe["i"], "users/email"); + let again = query(&keyset, &plan, "email", s("a@x"), &FakeEql) + .unwrap() + .await + .unwrap(); + assert_eq!( + serde_json::from_slice::(&again).unwrap()["hm"], + probe["hm"], + "deterministic" + ); + assert_eq!(generates(&cipher), 0, "a query mints nothing"); + // Not a target field, no such field, the wrong kind, and the + // bare build: each refused before the resolver runs. + assert!(matches!( + refused(query(&keyset, &plan, "age", s("x"), &FakeEql)), + Error::Plan + )); + assert!(matches!( + refused(query(&keyset, &plan, "nope", s("x"), &FakeEql)), + Error::Plan + )); + assert!(matches!( + refused(query( + &keyset, + &plan, + "email", + FfiValue::UInt32(1), + &FakeEql + )), + Error::Source + )); + assert!(matches!( + refused(query(&keyset, &plan, "email", s("x"), &NoTargets)), + Error::Target(TargetError::NoTargets { .. }) + )); + } + + #[tokio::test] + async fn a_resolver_refusal_names_the_field() { + // The resolver sees a type and a label; the lowering names the + // field the refusal was about. + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .unwrap(); + // A resolver that refuses the value as not its plaintext: the + // lowering's own kind check runs first, so reach the resolver + // with a kind it does not refuse here but the resolver does — + // there is none for a string target, so use the stored side. + let junk = CipherText::Map(vec![( + "email".to_string(), + CipherText::Map(vec![( + EQL_KEY.to_string(), + CipherText::Passthrough(Box::new(FfiValue::Bytes(Protected::new( + b"{}".to_vec(), + ))) as BoxedPassthrough), + )]), + )]); + let error = target_error(refused(decrypt_with( + Scope::Client(&cipher), + junk, + &plan, + &FakeEql, + ))); + match error { + TargetError::Stored { name, .. } => assert_eq!(name, "email"), + other => panic!("{other}"), + } + let _ = keyset; + } + } + /// The leaf encoding, pinned: every field lowered from data seals the /// tagged `Value` leaf whatever its `"type"`, so a type declared later /// changes no bytes; a Rust field of a bare type shares a data field's diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs new file mode 100644 index 000000000..8547d4b03 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -0,0 +1,594 @@ +//! EQL types as plan field targets: what the host that holds them installs. +//! +//! A data plan field may name an EQL type instead of its outputs +//! (`{"target": "TextEq", "context": [...]}`; see [`record::plan`]). The +//! engine knows no EQL type — `eql-bindings` depends on this crate, so this +//! crate cannot depend on it — and ADR-0007 (amended 2026-10-06) puts the +//! dispatch that resolves the name in the guest build that holds the EQL +//! types. This module is the seam: a [`TargetResolver`] the host installs, +//! which the lowering calls once per target field, and [`NoTargets`], the +//! resolver of a build without EQL types, which refuses every name. +//! +//! The resolver runs the named type's own plan (its `EncryptFrom` / +//! `DecryptInto`) and hands back the engine's [`Pending`], so a target field +//! settles in the same ZeroKMS request as the rest of the record: the +//! lowering zips it in. Nothing here derives a term or seals a byte; the +//! resolver reaches the engine through the typed call, as any Rust caller +//! does. +//! +//! # Wire format +//! +//! [`TargetDescriptor::to_value`] is the entry a guest's `se_targets` export +//! lists for each type; the keys are fixed on [`TargetDescriptor`], in the +//! crate that owns the data grammar, and `eql-bindings` is tested to spell +//! its table the same way. +//! +//! [`record::plan`]: super::record::plan() + +use std::fmt; + +use vitaminc_aead_value::{FfiValue, ValueKind}; + +use crate::{KeysetCipher, Label, Pending, StackCipher}; + +/// One EQL type a plan may name as a target, as the host describes it. +/// +/// # Wire format +/// +/// [`to_value`](Self::to_value) is the entry a guest's `se_targets` export +/// lists for each type, so a generator can ask the engine it embeds which +/// EQL types it holds. Every key is always present; an absent value is +/// null, never a missing key. +/// +/// | key | value | +/// |---|---| +/// | `name` | string: the type's name across languages, `TextEq`; what a plan's `"target"` names | +/// | `family` | string: the catalog family, `text` | +/// | `suffix` | string: the query-capability suffix, `Eq`; empty for a storage-only type | +/// | `plaintext` | string or null: the [`ValueKind`] name the type is produced from — the same names a field's `"type"` key uses — or null while unspecified | +/// | `sql_domain` | string: the stored value's PostgreSQL domain | +/// | `indexes` | list of strings: the indexes the type carries, by `IndexSpec::key()`, plus `json` for a SteVec document | +/// | `query` | string or null: the query twin's type name | +/// | `query_sql_domain` | string or null: the query twin's PostgreSQL domain | +/// | `producible` | bool: whether the engine produces the type today | +/// | `reason` | string or null: why not, when it does not | +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub struct TargetDescriptor { + /// The type's name across languages: `TextEq`. + pub name: String, + /// The catalog family: `text`. + pub family: String, + /// The query-capability suffix: `Eq`; empty for a storage-only type. + pub suffix: String, + /// The kind the type is produced from, or `None` while unspecified. + pub plaintext: Option, + /// The stored value's PostgreSQL domain. + pub sql_domain: String, + /// The indexes the type carries, by `IndexSpec::key()` name. + pub indexes: Vec, + /// The query twin's type name, or `None` for a storage-only type. + pub query: Option, + /// The query twin's PostgreSQL domain, or `None` likewise. + pub query_sql_domain: Option, + /// Whether the engine produces the type today. + pub producible: bool, + /// Why it does not, when it does not. + pub reason: Option, +} + +impl TargetDescriptor { + /// A descriptor with every field given. The one constructor, so a host + /// cannot leave a wire field out. + #[allow(clippy::too_many_arguments)] + pub fn new( + name: impl Into, + family: impl Into, + suffix: impl Into, + plaintext: Option, + sql_domain: impl Into, + indexes: Vec, + query: Option, + query_sql_domain: Option, + producible: bool, + reason: Option, + ) -> Self { + Self { + name: name.into(), + family: family.into(), + suffix: suffix.into(), + plaintext, + sql_domain: sql_domain.into(), + indexes, + query, + query_sql_domain, + producible, + reason, + } + } + + /// The descriptor in its wire form: the object a guest's `se_targets` + /// lists, every key present (see [the type's wire format](Self#wire-format)). + pub fn to_value(&self) -> FfiValue { + let text = |s: &str| FfiValue::String(s.into()); + let optional = |s: &Option| s.as_deref().map_or(FfiValue::Null, text); + FfiValue::Object(vec![ + ("name".to_string(), text(&self.name)), + ("family".to_string(), text(&self.family)), + ("suffix".to_string(), text(&self.suffix)), + ( + "plaintext".to_string(), + self.plaintext + .map_or(FfiValue::Null, |kind| text(kind.name())), + ), + ("sql_domain".to_string(), text(&self.sql_domain)), + ( + "indexes".to_string(), + FfiValue::Array(self.indexes.iter().map(|k| text(k)).collect()), + ), + ("query".to_string(), optional(&self.query)), + ( + "query_sql_domain".to_string(), + optional(&self.query_sql_domain), + ), + ("producible".to_string(), FfiValue::Bool(self.producible)), + ("reason".to_string(), optional(&self.reason)), + ]) + } +} + +/// Why a target field could not be resolved or run. +/// +/// Every variant but [`Other`](Self::Other) is a statement about the plan, +/// the value or the stored bytes, decided before any key is minted or +/// retrieved; a binding maps them to its malformed-input status. `Other` is +/// the resolver's own failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TargetError { + /// This build holds no EQL types: the plan names one, and only a build + /// linked with them can run it. + #[error("this build holds no EQL types; a plan cannot name {name} as a target")] + NoTargets { + /// The name the plan gave. + name: String, + }, + /// No EQL type has this name. + #[error("no such EQL type: {name}")] + Unknown { + /// The name the plan gave. + name: String, + }, + /// The type exists and the engine cannot produce it yet. + #[error("the engine cannot produce {name} yet: {reason}")] + Unproducible { + /// The type's name. + name: String, + /// The descriptor's reason. + reason: String, + }, + /// The type is produced and answers no query: a storage-only EQL type + /// has no query twin, so a query on a field that names it derives + /// nothing. + #[error("{name} answers no query: it is a storage-only type")] + NoQuery { + /// The type's name. + name: String, + }, + /// The plan extends every field's label by the caller's parts (a tenant, + /// a region), and an EQL value stores a table and a column only: there + /// is no column for the extended label. A plan with a target field is + /// not extended. + #[error( + "{name}: an EQL value is stored under a table and a column, so the label {label} \ + cannot be extended by the caller's parts" + )] + Extended { + /// The target field's name. + name: String, + /// The field's label, before the extension. + label: String, + }, + /// The field's declared `"type"` is not the kind the EQL type is + /// produced from. + #[error( + "{name}: {target} is produced from a {} plaintext, and the field declares {declared}", + expected.map_or("unspecified", ValueKind::name) + )] + Kind { + /// The field's name. + name: String, + /// The EQL type. + target: String, + /// The kind the type takes. + expected: Option, + /// The kind the field declared. + declared: ValueKind, + }, + /// The field's label is not a column the EQL type can be stored under. + #[error("{name}: the label {label} is not an EQL column: {reason}")] + Column { + /// The target field's name. + name: String, + /// The label. + label: String, + /// What the resolver said. + reason: String, + }, + /// The value is not of the type's plaintext kind. + #[error( + "{name}: {target} is produced from a {} plaintext, not {}", + expected.map_or("unspecified", ValueKind::name), + found.map_or("a value with no kind", ValueKind::name) + )] + Plaintext { + /// The field's name. + name: String, + /// The EQL type. + target: String, + /// The kind the type takes. + expected: Option, + /// The kind it was given, or `None` for a null, undefined or + /// passthrough value. + found: Option, + }, + /// The stored bytes are not a value of the type. + #[error("{name}: the stored value is not a {target}: {reason}")] + Stored { + /// The field's name. + name: String, + /// The EQL type. + target: String, + /// What the parser refused. + reason: String, + }, + /// The resolver's own failure: a value that did not serialize, an + /// invariant of the host's that did not hold. + #[error(transparent)] + Other(Box), +} + +/// The EQL types a build holds, and how to run one. +/// +/// A host installs one implementation: the guest build linked with the EQL +/// types implements it over `eql-bindings`' by-name dispatch; the build +/// without them installs [`NoTargets`]. The lowering +/// ([`record::encrypt_with`](super::record::encrypt_with) and its siblings) +/// calls it once per target field, with the field's label and value, and +/// zips the returned [`Pending`] into the record's. +/// +/// The three operations take the field's **label** — the plan's context and +/// the field's identity, `users/email` — and never an extension: an EQL +/// value is stored under a table and a column, and +/// [`record::Plan`](super::record::Plan) refuses a plan that both extends and +/// names a target ([`TargetError::Extended`]). +pub trait TargetResolver { + /// Every EQL type this build knows of, producible or not, in a fixed + /// order: what `se_targets` lists. + fn targets(&self) -> Vec; + + /// The descriptor of a type a plan may run: a name in + /// [`targets`](Self::targets) whose `producible` is true. + /// + /// # Errors + /// + /// [`TargetError::NoTargets`] when this build holds none, + /// [`TargetError::Unknown`] for a name no type has, and + /// [`TargetError::Unproducible`] with the descriptor's reason. + fn resolve(&self, name: &str) -> Result { + let targets = self.targets(); + if targets.is_empty() { + return Err(TargetError::NoTargets { + name: name.to_owned(), + }); + } + let descriptor = targets + .into_iter() + .find(|target| target.name == name) + .ok_or_else(|| TargetError::Unknown { + name: name.to_owned(), + })?; + if !descriptor.producible { + return Err(TargetError::Unproducible { + name: descriptor.name, + reason: descriptor + .reason + .unwrap_or_else(|| "no reason recorded".to_owned()), + }); + } + Ok(descriptor) + } + + /// Run the named type's own encryption plan over one value under + /// `label`, resolving to the EQL value's JSON bytes. + /// + /// # Errors + /// + /// The refusals of [`resolve`](Self::resolve), [`TargetError::Column`] + /// for a label that is not a column, and [`TargetError::Plaintext`] for a + /// value of another kind — all before any key is minted. The encryption + /// itself fails through the pending. + fn encrypt<'a, K: 'static>( + &self, + name: &str, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: FfiValue, + ) -> Result, K>, TargetError>; + + /// Open a stored value of the named type back to its plaintext, checking + /// it was stored under `label`'s column. Opens through the client; the + /// lowering confines the pending to a keyset when the caller's scope is + /// one. + /// + /// # Errors + /// + /// As [`encrypt`](Self::encrypt), with [`TargetError::Stored`] for bytes + /// that are not the type. A stored identifier that differs from `label`, + /// and a failed authentication, fail through the pending. + fn decrypt<'a, K: 'static>( + &self, + name: &str, + cipher: &'a StackCipher, + label: &Label, + stored: &[u8], + ) -> Result, TargetError>; + + /// Run the named type's query twin over one value: the operand that + /// matches stored values under `label`'s column, as JSON bytes. + /// + /// # Errors + /// + /// As [`encrypt`](Self::encrypt). + fn query<'a, K: 'static>( + &self, + name: &str, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: FfiValue, + ) -> Result, K>, TargetError>; +} + +/// The resolver of a build that holds no EQL types: every name is refused +/// with [`TargetError::NoTargets`], so a plan naming a target fails when it +/// is built ([`record::plan`](super::record::plan())), before any value is +/// read. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct NoTargets; + +impl NoTargets { + fn refuse(name: &str) -> Result { + Err(TargetError::NoTargets { + name: name.to_owned(), + }) + } +} + +impl TargetResolver for NoTargets { + fn targets(&self) -> Vec { + Vec::new() + } + + fn encrypt<'a, K: 'static>( + &self, + name: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + Self::refuse(name) + } + + fn decrypt<'a, K: 'static>( + &self, + name: &str, + _: &'a StackCipher, + _: &Label, + _: &[u8], + ) -> Result, TargetError> { + Self::refuse(name) + } + + fn query<'a, K: 'static>( + &self, + name: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + Self::refuse(name) + } +} + +impl fmt::Display for TargetDescriptor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text_eq() -> TargetDescriptor { + TargetDescriptor::new( + "TextEq", + "text", + "Eq", + Some(ValueKind::String), + "public.eql_v3_text_eq", + vec!["eq".to_string()], + Some("TextEqQuery".to_string()), + Some("eql_v3.query_text_eq".to_string()), + true, + None, + ) + } + + fn text_ord_ore() -> TargetDescriptor { + TargetDescriptor::new( + "TextOrdOre", + "text", + "OrdOre", + Some(ValueKind::String), + "public.eql_v3_text_ord_ore", + vec!["eq".to_string(), "ore".to_string()], + Some("TextOrdOreQuery".to_string()), + Some("eql_v3.query_text_ord_ore".to_string()), + false, + Some("block ORE is not CLLW ORE".to_string()), + ) + } + + /// A resolver over a fixed list, to test the default `resolve`. + struct Fixed(Vec); + + impl TargetResolver for Fixed { + fn targets(&self) -> Vec { + self.0.clone() + } + fn encrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("not run here") + } + fn decrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a StackCipher, + _: &Label, + _: &[u8], + ) -> Result, TargetError> { + unreachable!("not run here") + } + fn query<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("not run here") + } + } + + #[test] + fn the_wire_form_has_every_key_in_order_with_null_for_absent() { + let FfiValue::Object(entries) = text_eq().to_value() else { + panic!("an object"); + }; + let keys: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + keys, + [ + "name", + "family", + "suffix", + "plaintext", + "sql_domain", + "indexes", + "query", + "query_sql_domain", + "producible", + "reason" + ], + "the se_targets entry's keys, in order" + ); + assert!(matches!(&entries[3].1, FfiValue::String(s) if s.risky_ref() == b"string")); + assert!(matches!(&entries[8].1, FfiValue::Bool(true))); + assert!( + matches!(&entries[9].1, FfiValue::Null), + "no reason is null, not absent" + ); + let mut unspecified = text_eq(); + unspecified.plaintext = None; + unspecified.query = None; + let FfiValue::Object(entries) = unspecified.to_value() else { + panic!("an object"); + }; + assert!(matches!(&entries[3].1, FfiValue::Null)); + assert!(matches!(&entries[6].1, FfiValue::Null)); + assert_eq!( + entries.len(), + 10, + "an absent value is null, never a missing key" + ); + } + + #[test] + fn resolve_refuses_by_the_table_before_any_name_is_matched() { + let empty = Fixed(vec![]); + assert!( + matches!(empty.resolve("TextEq"), Err(TargetError::NoTargets { name }) if name == "TextEq"), + "an empty table is a build without EQL types" + ); + let fixed = Fixed(vec![text_eq(), text_ord_ore()]); + assert_eq!(fixed.resolve("TextEq").unwrap().name, "TextEq"); + assert!( + matches!(fixed.resolve("Nope"), Err(TargetError::Unknown { name }) if name == "Nope") + ); + assert!(matches!( + fixed.resolve("TextOrdOre"), + Err(TargetError::Unproducible { name, reason }) if name == "TextOrdOre" && reason.contains("CLLW") + )); + assert!( + matches!(fixed.resolve("texteq"), Err(TargetError::Unknown { .. })), + "names match exactly" + ); + } + + /// A descriptor displays as its name alone, producible or not: what a + /// log line or an error names a type by, with no status attached. + #[test] + fn a_descriptor_displays_as_its_name() { + assert_eq!(text_eq().to_string(), "TextEq"); + assert_eq!(format!("{}", text_ord_ore()), "TextOrdOre"); + assert_eq!( + format!("targets: {}, {}", text_eq(), text_ord_ore()), + "targets: TextEq, TextOrdOre" + ); + } + + #[test] + fn no_targets_refuses_every_name_with_the_build_reason() { + let error = NoTargets.resolve("TextEq").unwrap_err(); + assert_eq!( + error.to_string(), + "this build holds no EQL types; a plan cannot name TextEq as a target" + ); + assert!(NoTargets.targets().is_empty()); + } + + #[test] + fn error_messages_name_the_field_and_the_type() { + let error = TargetError::Kind { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + declared: ValueKind::UInt64, + }; + assert_eq!( + error.to_string(), + "email: TextEq is produced from a string plaintext, and the field declares uint64" + ); + let error = TargetError::Plaintext { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + found: None, + }; + assert_eq!( + error.to_string(), + "email: TextEq is produced from a string plaintext, not a value with no kind" + ); + let error = TargetError::Extended { + name: "email".into(), + label: "users/email".into(), + }; + assert!(error.to_string().contains("cannot be extended"), "{error}"); + } +} diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs index 288be3628..ed7d965cd 100644 --- a/scripts/__tests__/cargo-lock-freshness.test.mjs +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -170,13 +170,18 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its // `Cargo.toml` on every release. If this crate ever drops out of the pair // set, the check that matters most has silently stopped running. It is - // in the two locks whose workspaces build it; the stack-* workspaces do - // not depend on it. + // in the three locks whose workspaces build it: EQL's own, protect-ffi's, + // and the stack-encrypt Go guest's, whose `eql` build links it by path + // (ADR-0007, amended). The stack-* workspaces do not depend on it. The + // bump's own set is discovered by walking (`cargoLockWorkspaces`), so a + // lock added here is one it already rewrites; this pins that the walk + // still sees all three. expect( PAIRS.filter(({ name }) => name === 'eql-bindings') .map(({ lock }) => lock) .sort(), ).toEqual([ + 'languages/golang/encrypt/guest/Cargo.lock', 'languages/typescript/packages/protect-ffi/Cargo.lock', 'packages/eql/Cargo.lock', ]) diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 8ba565ffe..c5b60101b 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -9,19 +9,20 @@ # the Rust build is the slow part. # # Usage: go-binding-test.sh [...] -# With no guest paths, both guests the module embeds are expected. +# With no guest paths, every guest the module embeds is expected: the +# stack-encrypt guest, its build with the EQL types, and the credential guest. set -euo pipefail dir=${1:?usage: go-binding-test.sh [...]} shift || true if [ $# -eq 0 ]; then - set -- encrypt/wasm/stack_encrypt_guest.wasm auth/wasm/stack_auth_guest.wasm + set -- encrypt/wasm/stack_encrypt_guest.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm auth/wasm/stack_auth_guest.wasm fi cd "$dir" for guest in "$@"; do if [ ! -f "$guest" ]; then - echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:auth-guest:build" >&2 + echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:guest:build:eql wasm:auth-guest:build" >&2 exit 1 fi done