Skip to content

Commit 264dc94

Browse files
authored
Merge pull request #971 from cipherstash/codex/eql-texteq-transcoding
feat(eql): add Rust text encryption and queries
2 parents a86711f + 7fb5e37 commit 264dc94

186 files changed

Lines changed: 5485 additions & 569 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@cipherstash/eql': minor
3+
---
4+
5+
**Rust `TextEq` and `TextEqQuery` support Stack Encrypt's target-directed API behind the `stack-encrypt` feature.** The stored table and column identifier supplies the encryption context; native ciphertext and equality terms are transcoded into EQL payloads while Vitamin C owns plaintext encoding. This is a new producer profile with exact string equality, independent of existing cipherstash-client ciphertext and terms. Query operands carry no recoverable ciphertext.

‎.github/workflows/bench-eql.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ on:
6464
- "packages/eql/docker/**"
6565
- "packages/eql/README.md"
6666
- "packages/eql/SUPABASE.md"
67+
- "packages/stack-encrypt/**"
68+
- "packages/stack-encrypt-derive/**"
69+
- "packages/stack-kms/**"
70+
- "packages/stack-auth/**"
71+
- "packages/stack-profile/**"
72+
- "Cargo.toml"
6773

6874
schedule:
6975
# 02:00 UTC daily

‎.github/workflows/release-plz.yml‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ name: "Release crates (crates.io)"
22

33
# Publishes two crates.io release lines via release-plz (crates.io Trusted
44
# Publishing over OIDC — no CARGO_REGISTRY_TOKEN): `eql-bindings` from
5-
# packages/eql (`release`), and `stack-auth` + `stack-profile` from the root
6-
# workspace (`release-crates`, described above its own jobs at the end).
5+
# packages/eql (`release`), and the stack-* crates from the root workspace
6+
# (`release-crates`, described above its own jobs at the end). `release` runs
7+
# after `release-crates`: see the comment on its `needs:`.
78
# Everything from here to `permissions:` is about the EQL line.
89
#
910
# THE FILENAME CANNOT CHANGE: crates.io binds the publisher to it.
@@ -85,8 +86,23 @@ jobs:
8586

8687
release:
8788
name: "Release"
88-
needs: [eql-armed]
89-
if: needs.eql-armed.outputs.armed == 'true'
89+
# AFTER `release-crates`, not beside it. eql-bindings' `stack-encrypt`
90+
# feature names a stack-encrypt VERSION, and `cargo publish` resolves it
91+
# from crates.io, so on a push that releases both, publishing in parallel
92+
# races this job's resolution against the other job's upload. Ordered,
93+
# the stack-* crates land first and this job's verify finds them.
94+
# `!cancelled()`, not the implicit `success()`: `release-crates` is
95+
# skipped on every push that moves no stack-* crate, and a skipped
96+
# dependency would otherwise skip this job too. Not `always()` either:
97+
# that would start this job after somebody cancelled the run, and a
98+
# crates.io upload cannot be undone, only yanked (see the same rule on
99+
# the publishing jobs in release.yml). A FAILURE of `release-crates`
100+
# still stops this one, since the crates it needed did not publish.
101+
needs: [eql-armed, release-crates]
102+
if: >-
103+
!cancelled()
104+
&& needs.eql-armed.outputs.armed == 'true'
105+
&& (needs.release-crates.result == 'success' || needs.release-crates.result == 'skipped')
90106
# GitHub-hosted, matching this repo's other publishing jobs.
91107
runs-on: ubuntu-latest
92108
timeout-minutes: 30

‎.github/workflows/test-eql.yml‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,20 @@ on:
100100
- "packages/eql/docker/**"
101101
- "packages/eql/README.md"
102102
- "packages/eql/SUPABASE.md"
103+
# The `rust-crates` job's encryption steps compile these by PATH
104+
# (`eql-bindings`' `stack-encrypt` feature): stack-encrypt, its derive
105+
# and stack-kms through `path =`, and stack-auth and stack-profile
106+
# through `workspace = true` entries the root `Cargo.toml` resolves to
107+
# paths. The mise-task derivation in eql-workflow-filters.test.mjs
108+
# cannot see them (a task body names a cargo package, not a path); the
109+
# manifest walk in eql-suite-ci.test.mjs does, and fails when one of them
110+
# is missing from this list.
111+
- "packages/stack-encrypt/**"
112+
- "packages/stack-encrypt-derive/**"
113+
- "packages/stack-kms/**"
114+
- "packages/stack-auth/**"
115+
- "packages/stack-profile/**"
116+
- "Cargo.toml"
103117
schedule:
104118
- cron: "0 4 * * *" # 04:00 UTC daily; full matrix, off the merge path
105119
merge_group: {} # inert today (no queue); kept so enabling one works
@@ -193,6 +207,12 @@ jobs:
193207
- "packages/eql/docker/**"
194208
- "packages/eql/README.md"
195209
- "packages/eql/SUPABASE.md"
210+
- "packages/stack-encrypt/**"
211+
- "packages/stack-encrypt-derive/**"
212+
- "packages/stack-kms/**"
213+
- "packages/stack-auth/**"
214+
- "packages/stack-profile/**"
215+
- "Cargo.toml"
196216
197217
# Explicit default (not `|| 'true'`, which trips GitHub's inconsistent
198218
# treatment of the string 'false'). push/schedule/merge_group/dispatch
@@ -520,6 +540,18 @@ jobs:
520540
github.event_name != 'pull_request'
521541
|| needs.changes.outputs.relevant == 'true'
522542
runs-on: blacksmith-16vcpu-ubuntu-2204
543+
services:
544+
encryption-postgres:
545+
image: postgres:17
546+
env:
547+
POSTGRES_PASSWORD: postgres
548+
ports:
549+
- 7433:5432
550+
options: >-
551+
--health-cmd pg_isready
552+
--health-interval 10s
553+
--health-timeout 5s
554+
--health-retries 5
523555
steps:
524556
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
525557
with:
@@ -549,6 +581,23 @@ jobs:
549581
rustup component add --toolchain "${active_rust_toolchain}" rustfmt clippy
550582
mise run test:crates
551583
584+
# The three encryption checks are mise tasks rather than raw cargo here,
585+
# so `eql-suite-ci.test.mjs` counts them among the cargo tasks a root
586+
# workflow must reach, and `eql-workflow-filters.test.mjs` derives the
587+
# paths they read. The database task takes the connection from
588+
# EQL_TEST_DATABASE_URL alone; the port is written once more, in the
589+
# service's `ports:`, which no expression can reach from here.
590+
- name: Test Rust text equality
591+
run: mise run test:encryption
592+
593+
- name: Test Rust text equality with PostgreSQL
594+
env:
595+
EQL_TEST_DATABASE_URL: "host=localhost port=7433 user=postgres password=postgres dbname=postgres"
596+
run: mise run test:encryption:postgres
597+
598+
- name: Compile Rust text equality for WASI without HTTP
599+
run: mise run check:encryption:wasi
600+
552601
# Freshness gate for the eql-types codegen output: regenerate the
553602
# TypeScript bindings and JSON Schemas and fail if the checked-in
554603
# copies differ. Reuses the toolchain from the step above.
@@ -562,9 +611,46 @@ jobs:
562611
# a real path dependency without a version — on the PR rather than at
563612
# release time. No token needed. `--allow-dirty` tolerates any files the
564613
# preceding regenerate-and-diff steps leave in the working tree.
614+
#
615+
# `--all-features`: the verify step compiles the DEFAULT feature set
616+
# unless told otherwise, and the `stack-encrypt` feature is off by
617+
# default — so this step passed while that feature did not build against
618+
# the crates.io stack-encrypt the manifest names. Every other job here
619+
# compiles stack-encrypt by PATH, from the tree, and cannot notice; the
620+
# packaged manifest has no `path`, so this verify resolves stack-encrypt
621+
# from the REGISTRY, which is the build a consumer and docs.rs get.
622+
# release-plz does the same at publish time (`publish_all_features` in
623+
# packages/eql/release-plz.toml).
624+
#
625+
# Two ways the verify can fail, and only one of them is this step's job.
626+
# A stack-encrypt VERSION that is not on crates.io yet fails resolution
627+
# ("failed to select a version") before anything compiles. That is the
628+
# ordinary state of a PR that bumps the requirement: the stack-encrypt it
629+
# names ships from main, this PR is not on main, and the two cannot be
630+
# published in the other order. It is also already loud where it
631+
# matters — `cargo publish` refuses it at release time, and
632+
# release-plz.yml publishes the stack-* crates before eql-bindings — so
633+
# here it is a warning, not a failure, and the registry build is left to
634+
# the release. A version that RESOLVES and then does not compile is the
635+
# silent case, the one a path build can never see, and that fails.
565636
- name: Verify eql-bindings packages cleanly for crates.io
566637
run: |
567-
cargo publish -p eql-bindings --dry-run --allow-dirty
638+
set -euo pipefail
639+
# cargo's own status is kept in `status`: `|| status=$?` reads it
640+
# before anything else runs. (`status=$?` on the line after an `if`
641+
# reads the if's status, which is 0 when its condition failed, so a
642+
# version that resolves and then fails to compile would exit 0.)
643+
status=0
644+
out="$(cargo publish -p eql-bindings --dry-run --allow-dirty --all-features 2>&1)" || status=$?
645+
printf '%s\n' "$out"
646+
if [ "$status" -eq 0 ]; then
647+
exit 0
648+
fi
649+
if grep -qF 'failed to select a version for the requirement `stack-encrypt' <<<"$out"; then
650+
echo "::warning title=eql-bindings not verified against the registry::The stack-encrypt version eql-bindings names is not on crates.io yet, so the packaged crate could not be built against the registry here. release-plz builds it at publish time (publish_all_features in packages/eql/release-plz.toml) and refuses to publish eql-bindings until that stack-encrypt has shipped."
651+
exit 0
652+
fi
653+
exit "$status"
568654
569655
codegen:
570656
name: "Encrypted-domain codegen"

‎AGENTS.md‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l
8585
- `languages/typescript/packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`)
8686
- `languages/typescript/packages/bench`: Performance / index-engagement benchmarks (private, not published)
8787
- `languages/typescript/packages/protect-ffi`: Native FFI bindings to the CipherStash Client SDK (`@cipherstash/protect-ffi`) — the Rust core that `languages/typescript/packages/stack` encrypts and decrypts through, absorbed from `cipherstash/protectjs-ffi`. Contains a **nested Cargo workspace** (`crates/`) and six per-platform binary packages under `platforms/*`, each published as `@cipherstash/protect-ffi-<platform>` and linked here via `workspace:*`. Also holds the repo's live FFI integration suite at `integration-tests/` — a private workspace member (`@cipherstash/ffi-integration-tests`) enrolled by its own literal entry in `pnpm-workspace.yaml`, needing Docker and credentials, and deliberately carrying **no `test` script** so `pnpm test` cannot reach it. See the "Working on protect-ffi" notes below before touching it — its default `test` and `build` are deliberately Rust-free.
88-
- `packages/eql`: The Encrypt Query Language subtree — the SQL bundle that stores and queries encrypted payloads — absorbed from `cipherstash/encrypt-query-language`. **The directory is the subtree root, not the package.** It was imported at a *verbatim prefix* so its repo-root-relative paths (mise tasks, `Doxyfile`, `sync-generated.mjs`) keep resolving, which puts the npm package `@cipherstash/eql` two levels down at `packages/eql/packages/eql` — the same shape as `languages/typescript/packages/protect-ffi/platforms/*`, and enrolled the same way, by an explicit `packages/eql/packages/*` glob in `pnpm-workspace.yaml`. The subtree root deliberately carries no `package.json`. Also contains a **nested Cargo workspace** at `packages/eql/crates/` (`eql-bindings`, published in lockstep with the npm package, plus `eql-domains` / `eql-codegen` / `eql-tests-macros`, which are not), a SQLx test crate at `packages/eql/tests/sqlx`, an ~900-line `mise.toml` task surface, its own `AGENTS.md`, and `docs/`. See the "Working on EQL" notes below before touching it.
88+
- `packages/eql`: The Encrypt Query Language subtree — the SQL bundle that stores and queries encrypted payloads — absorbed from `cipherstash/encrypt-query-language`. **The directory is the subtree root, not the package.** It was imported at a *verbatim prefix* so its repo-root-relative paths (mise tasks, `Doxyfile`, `sync-generated.mjs`) keep resolving, which puts the npm package `@cipherstash/eql` two levels down at `packages/eql/packages/eql` — the same shape as `languages/typescript/packages/protect-ffi/platforms/*`, and enrolled the same way, by an explicit `packages/eql/packages/*` glob in `pnpm-workspace.yaml`. The subtree root deliberately carries no `package.json`. Also contains a **nested Cargo workspace** at `packages/eql/crates/` (`eql-bindings`, published in lockstep with the npm package, plus `eql-domains` / `eql-codegen` / `eql-tests-macros`, which are not), a SQLx test crate at `packages/eql/tests/sqlx`, an unpublished encryption test crate at `packages/eql/tests/encryption` (real encryption with a fake key source, the executable Rustdoc example at `packages/eql/crates/eql-bindings/src/encryption/example.rs`, plus optional PostgreSQL coverage), an ~900-line `mise.toml` task surface, its own `AGENTS.md`, and `docs/`. See the "Working on EQL" notes below before touching it.
8989
**Repository ownership:** EQL now lives in `cipherstash/stack`. File and update
9090
EQL issues in this repository, never in the historical
9191
`cipherstash/encrypt-query-language` repository. Old upstream issue and PR
@@ -504,6 +504,30 @@ monorepo, which is where the silent failures are.
504504
changes what the whole EQL CI surface compiles against, and with the skip in
505505
place the release-stopper is closed without it. If wanted, the pin belongs
506506
upstream and arrives by subtree pull.
507+
- **`eql-bindings`' `stack-encrypt` feature names a stack-encrypt VERSION, and
508+
the published crate is built against that version, not the tree.** The
509+
dependency is `path` + `version`: the path is what every in-tree job
510+
compiles, the version is what crates.io resolves once `cargo publish` strips
511+
the path. The two agree only if the stack-encrypt on crates.io at that
512+
version has the API `src/encryption.rs` uses — and nothing in a path build
513+
can tell. stack-encrypt 0.1.0 shipped without `Describe`, and the feature
514+
would have published against it while every test passed. Two guards compile
515+
the feature FROM THE PACKAGED CRATE against the registry: `cargo publish
516+
--dry-run --all-features` in `test-eql.yml`'s `rust-crates` job, and
517+
`publish_all_features = true` in `packages/eql/release-plz.toml`. Without
518+
`--all-features` both verify the default feature set, which is the feature
519+
off. Consequences: the stack-encrypt a bump of that requirement names must
520+
reach crates.io before eql-bindings does (the root release-plz line is
521+
publish-only — a stack-* version moves by a hand-edited `Cargo.toml`, and
522+
cargo refuses a requirement the in-tree path dependency does not satisfy, so
523+
the bump lands in the stack-encrypt PR first); on a PR that names a
524+
stack-encrypt not yet on crates.io the dry-run step cannot build against
525+
the registry and says so as a WARNING rather than failing, because that PR
526+
merges before the crate can ship and the case is already loud at publish —
527+
only a version that resolves and then does not compile fails the step; and
528+
`release-plz.yml`'s `release` job runs after `release-crates` so that on a
529+
push releasing both, the crate eql-bindings resolves exists by the time it
530+
looks.
507531
- **`eql-bindings` resolves by path from `languages/typescript/packages/protect-ffi`, never from
508532
crates.io**, and `scripts/lint-no-eql-registry-pins.mjs` (`pnpm run
509533
lint:eql-pins`) is what keeps it that way. The two halves of EQL are the Rust

‎CONTEXT-MAP.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
- [EQL](./packages/eql/CONTEXT.md) — defines the PostgreSQL objects that store
66
and query encrypted values.
7+
- [Stack Encrypt](./packages/stack-encrypt/CONTEXT.md) — encrypts values under
8+
per-value ZeroKMS data keys and derives searchable terms from them, for Rust
9+
and, through a WASI guest, Go.
710

811
## Relationships
912

@@ -12,3 +15,6 @@
1215
- **EQL → ORM adapters**: EQL defines durable encrypted column domains and
1316
disposable query machinery; adapters create application columns and derived
1417
search indexes against that surface.
18+
- **Stack Encrypt → EQL**: Stack Encrypt seals values and renders the ZeroKMS
19+
descriptor; EQL's `eql-bindings` transcodes those into EQL payloads, and its
20+
`Identifier` (a table and a column) is a two-segment Stack Encrypt `Label`.

‎Cargo.lock‎

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎SECURITY.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ includes the optional `stack-encrypt` feature of the MIT-licensed
4444
`eql-bindings` crate, which depends on `stack-encrypt` and so is usable only
4545
under the same terms.
4646

47+
The `eql-encryption-tests` Rust crate (`packages/eql/tests/encryption`) is
48+
an unpublished test harness for these bindings. It uses real encryption with
49+
a fake key source, executes the Rustdoc text encryption example, and provides
50+
optional disposable PostgreSQL coverage. It adds no published package or
51+
production service.
52+
4753
> **Note on publishing.** Every package in the table above, including all
4854
> seven `@cipherstash/protect-ffi*` packages, all seven `@cipherstash/auth*`
4955
> packages and `@cipherstash/eql`, is published from this repository by

0 commit comments

Comments
 (0)