diff --git a/.changeset/brave-hounds-monitor.md b/.changeset/brave-hounds-monitor.md new file mode 100644 index 000000000..ad2f37495 --- /dev/null +++ b/.changeset/brave-hounds-monitor.md @@ -0,0 +1,18 @@ +--- +'stash': patch +--- + +Correct the Dependabot section of the bundled `stash-supply-chain-security` +skill. It described two monitored ecosystems (`npm`, `github-actions`); there +are now three, because the in-tree Rust workspace at `packages/protect-ffi` +brought a `Cargo.lock` that nothing proposed updates for. The skill now names +the `cargo` entry, its non-root `directory`, its monthly cadence, and the +exact-pinned CipherStash crates it ignores. + +Two things the section previously got wrong are also fixed. Major bumps do not +"stay un-grouped — one PR each": every entry ignores +`version-update:semver-major`, so Dependabot proposes no major bumps at all and +they are applied by hand. And `ignore` conditions suppress Dependabot *security* +PRs as well as version updates — the skill now says so, and points at +`osv-scanner.yml` (which scans every lockfile in the tree, `Cargo.lock` +included) as the compensating control. diff --git a/.changeset/config.json b/.changeset/config.json index fb05a4c16..00e1e6a33 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,6 +10,15 @@ "@cipherstash/stack-supabase", "@cipherstash/stack-prisma", "@cipherstash/wizard" + ], + [ + "@cipherstash/protect-ffi", + "@cipherstash/protect-ffi-darwin-x64", + "@cipherstash/protect-ffi-darwin-arm64", + "@cipherstash/protect-ffi-win32-x64-msvc", + "@cipherstash/protect-ffi-linux-x64-gnu", + "@cipherstash/protect-ffi-linux-arm64-gnu", + "@cipherstash/protect-ffi-linux-x64-musl" ] ], "linked": [], diff --git a/.changeset/protect-ffi-lazy-load.md.deferred b/.changeset/protect-ffi-lazy-load.md.deferred new file mode 100644 index 000000000..2dd592866 --- /dev/null +++ b/.changeset/protect-ffi-lazy-load.md.deferred @@ -0,0 +1,7 @@ +--- +'@cipherstash/protect-ffi': minor +--- + +The native binding now loads lazily. Importing the package no longer resolves the platform binary: the CJS entry pulls in `./load.cjs` with `import native = require(...)`, which emits a plain `require` and leaves the `@neon-rs/load` proxy untouched, where the previous `import * as native from` compiled to an `__importStar` that enumerated the module and forced resolution at module-evaluation time. `require('@cipherstash/protect-ffi')` therefore succeeds with no binding installed, and the same `MODULE_NOT_FOUND` — identical `code` and `message` — is raised on first use instead of at import. + +Adds `assertNativeBindingAvailable()`: a diagnostic entry point that resolves the platform binary and throws the loader's error unwrapped if it is missing, without constructing a client, reading credentials or touching the network. It exists because laziness removed the implicit probe that importing the package used to be, and there is no consumer-side replacement — `lib/load.cjs` is not an exported path (`ERR_PACKAGE_PATH_NOT_EXPORTED`), reading one of this module's own exports never reaches the proxy, and forcing it through a real wrapper means picking one whose argument validation does not reject first. diff --git a/.changeset/quiet-moons-repair.md b/.changeset/quiet-moons-repair.md new file mode 100644 index 000000000..0687c7be2 --- /dev/null +++ b/.changeset/quiet-moons-repair.md @@ -0,0 +1,5 @@ +--- +'stash': patch +--- + +Document the Dependabot major-version policy in `skills/stash-supply-chain-security`: no entry configures a `semver-major-days` cooldown, because every entry ignores `version-update:semver-major` and cooldown applies to version updates only. The supply-chain e2e suite now pins both halves of that relationship. diff --git a/.github/actions/build-ffi-binding/action.yml b/.github/actions/build-ffi-binding/action.yml new file mode 100644 index 000000000..ea5bc72e8 --- /dev/null +++ b/.github/actions/build-ffi-binding/action.yml @@ -0,0 +1,229 @@ +name: Build the protect-ffi binding +description: >- + Compile `packages/protect-ffi` into the artifacts its JS consumers load at + runtime — `lib/` (tsc), `index.node` (cargo), and optionally `dist/wasm/**` + (wasm-pack) — then prove they load. + + WHY THIS EXISTS: until the monorepo absorption, `@cipherstash/protect-ffi` + was installed from npm and all three arrived prebuilt inside the tarball. As + a workspace package it ships source only, so every job that encrypts, + decrypts, or typechecks against it has to build them first — and nothing else + in CI does. Without this action the credentialed suites fail with + `Cannot find module '.../protect-ffi-linux-x64-gnu/index.node'`, the Deno + smoke test with `Module not found ".../dist/wasm/protect_ffi_inline.js"`, and + stack's type tests with four unresolved-declaration errors. + + DO NOT USE FROM A PUBLISHING WORKFLOW. It restores the GitHub Actions cache, + which `scripts/lint-no-workflow-caching.mjs` forbids anywhere an artifact + gets published. Release builds go through the target-explicit matrix instead, + which compiles every platform from scratch. + +inputs: + wasm: + description: >- + Also build `dist/wasm/**`. Only jobs that exercise + `@cipherstash/stack/wasm-inline` need it — the Deno smoke test and the + `integration/wasm/**` suites — and it costs a wasm-pack install plus a + second cargo build against wasm32, so it is off by default. + required: false + default: 'false' + +runs: + using: composite + steps: + # `index.node` is a pure function of the Rust inputs, so it caches on a + # content hash of them rather than on a lockfile alone — a crates/ edit + # must miss. The path is one gitignored file, so unlike the WASM step + # below there is nothing tracked for a restore to overwrite, and the key + # needs nothing beyond the Rust inputs. Caching the 13MB output, NOT + # cargo's `target/`: a release + # build of the cipherstash-client graph leaves gigabytes there, which is + # slower to save and restore than the compile it saves. + # + # GitHub scopes a PR's cache writes to its own ref, so the payoff arrives + # once this lands on main and PR runs can read main's entry. Until then + # each PR pays one cold build and reuses it across re-runs. + - name: Restore the compiled binding + id: cache-native + uses: actions/cache@v4 + with: + path: packages/protect-ffi/index.node + # package.json and mise.toml are in the key because they are build + # INPUTS, not just metadata: `build:native` is `cargo-build` plus a + # `postcargo-build` hook (`neon dist < cargo.log`), both defined in + # package.json, and mise.toml pins the toolchain that compiles it. + # Edit either and the Rust hash is unchanged, so the cache hit skips + # the build step below and the job proceeds on a stale index.node. + key: >- + ffi-native-${{ runner.os }}-${{ runner.arch }}-${{ + hashFiles('packages/protect-ffi/crates/**', + 'packages/protect-ffi/Cargo.toml', + 'packages/protect-ffi/Cargo.lock', + 'packages/protect-ffi/package.json', + 'packages/protect-ffi/mise.toml') }} + + # Always, hit or miss: `lib/` is `tsc` over `src/`, which is seconds, and + # it tracks a different input set than the cached binary. + - name: Build lib/ (the package `main`, and what tsc consumers resolve) + shell: bash + run: pnpm --filter @cipherstash/protect-ffi run build + + # `build:native` is `tsc && cargo build --release`, then a `postcargo-build` + # hook running `neon dist < cargo.log`. That bare `neon dist` writes + # `index.node` at the PACKAGE ROOT, which is what `src/load.cts` registers + # as its `debug:` fallback — so the loader finds it without any platform + # package being populated. Release packaging needs `neon dist -o` to place + # a binary inside `platforms/

/`; testing deliberately does not. + - name: Build index.node (cargo) + if: steps.cache-native.outputs.cache-hit != 'true' + shell: bash + run: pnpm --filter @cipherstash/protect-ffi run build:native + + # Unlike index.node, this path is NOT purely generated. `dist/wasm` holds + # wasm-pack's output alongside three declaration files that are tracked in + # git — see packages/protect-ffi/.gitignore for why they have to be. A + # restore untars over the checkout, so with a key hashed from the Rust + # inputs alone, an entry saved before a `.d.ts` edit silently reverts that + # edit: no diff, no log line, and the next `tsc` against wasm-inline + # typechecks the old declarations. + # + # So the `.d.ts` are hashed into the key too. Any edit to one now misses, + # and any entry that *does* hit was necessarily saved from a checkout whose + # declarations were byte-identical — which makes the overwrite a no-op by + # construction rather than by luck. The glob is load-bearing: it covers a + # fourth declaration file the day someone tracks one, where three literal + # paths would quietly stop covering the set they were written for. + # + # The alternative — keeping them out of the archive with a `!` exclusion — + # does not work, and reads as though it does. `actions/cache` resolves + # `path:` with `implicitDescendants: false` and hands the result to + # `tar --files-from`; a bare directory therefore resolves to one entry and + # tar recurses into it itself, so an exclusion aimed at files underneath + # never matches anything. Narrowing to explicit file globs would work, but + # trades a no-op overwrite for a worse failure: a file wasm-pack starts + # emitting is then silently absent on every cache hit. + # + # No key prefix bump is needed for the changed hash inputs — and none would + # be needed had `path:` changed either, since `getCacheVersion` hashes the + # path list into the entry's version. + - name: Restore the compiled WASM output + if: inputs.wasm == 'true' + id: cache-wasm + uses: actions/cache@v4 + with: + path: packages/protect-ffi/dist/wasm + # The last five are the same build-input argument as on the native key, + # and `build:wasm` is the longer pipeline of the two: wasm-pack, then + # `tsc -p tsconfig.wasm-errors.json`, then a `postbuild:wasm` hook + # running scripts/inline-wasm.mjs — which is what emits the + # protect_ffi_inline.js that stack's wasm-inline entry imports. An edit + # to the inliner with no Rust change is the likely one here, and it is + # exactly the edit a Rust-only hash cannot see. + # + # src/errors.ts is that tsc step's only input (`files:` in the tsconfig + # names it alone), and hashing the EMITTED dist/wasm/*.d.ts does not + # stand in for it. Rewriting the body of `isProtectErrorCode` leaves + # errors.d.ts byte-identical while changing errors.js — so the key hits, + # this step is skipped, and the restored errors.js is the old one. + # Nothing tracked forces a miss: errors.js is gitignored. It is not + # inert output either — scripts/inline-wasm.mjs re-exports it from both + # the ./wasm and ./wasm-inline entries, so the WASM integration suites + # would run an implementation the release build does not ship. + # + # It is the one source file here for the same reason the whole of src/** + # is NOT in this key: a wasm miss costs a wasm-pack install and a cargo + # wasm32 build of the cipherstash-client graph, and 104 of the 122 files + # under src/ are the eql-v3-types declarations, which that tsconfig + # never reads. ffi-binding-action.test.mjs walks the tsconfig's imports + # and fails if this stops being the complete input set. + key: >- + ffi-wasm-${{ runner.os }}-${{ + hashFiles('packages/protect-ffi/crates/**', + 'packages/protect-ffi/Cargo.toml', + 'packages/protect-ffi/Cargo.lock', + 'packages/protect-ffi/dist/wasm/*.d.ts', + 'packages/protect-ffi/src/errors.ts', + 'packages/protect-ffi/package.json', + 'packages/protect-ffi/mise.toml', + 'packages/protect-ffi/tsconfig.wasm-errors.json', + 'packages/protect-ffi/scripts/inline-wasm.mjs') }} + + # mise carries the pinned wasm-pack (see packages/protect-ffi/mise.toml) + # and, run from that directory, trusts the nested config — a bare `mise` + # call elsewhere refuses it with "Config files are not trusted", which + # reads as a toolchain problem rather than a trust one. + # + # `install_args` narrows this to wasm-pack alone. A bare `mise install` + # would also build `cargo:cargo-zigbuild` from source, which this job has + # no use for — zigbuild is the release matrix's glibc-pinning path. + # + # The argument is the full backend id, matching mise.toml. The short name + # is not in mise's registry: `mise install wasm-pack` exits non-zero with + # "not found in mise tool registry" and a did-you-mean list. + # + # SHA-pinned, per the convention the deposited upstream workflows already + # use for third-party actions. mise-action is a trust dependency this repo + # did not have before the absorption, and it runs in jobs holding live + # CipherStash credentials — a mutable `@v3` lets the code executing there + # change with no commit here. Dependabot moves the pin and the comment + # together (.github/dependabot.yml covers github-actions). + - name: Install wasm-pack + if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true' + uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + install: true + install_args: aqua:wasm-bindgen/wasm-pack + working_directory: packages/protect-ffi + + # `--all-targets` in the Rust lint means all target KINDS, not platforms; + # wasm32 has to be installed explicitly before anything can build for it. + - name: Add the wasm32 target + if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true' + shell: bash + run: rustup target add wasm32-unknown-unknown + + - name: Build dist/wasm (wasm-pack) + if: inputs.wasm == 'true' && steps.cache-wasm.outputs.cache-hit != 'true' + shell: bash + run: pnpm --filter @cipherstash/protect-ffi run build:wasm + + # Always, and the reason this action is not just three `run:` lines inline. + # A cache that restores nothing, or a build whose output lands somewhere + # unexpected, otherwise surfaces as dozens of unrelated encryption failures + # deep in a credentialed suite. Fail here instead, naming the artifact. + - name: Verify the binding loads + shell: bash + working-directory: packages/protect-ffi + env: + WANT_WASM: ${{ inputs.wasm }} + run: | + set -euo pipefail + + test -s index.node || { + echo "::error::index.node was neither restored from cache nor built" + exit 1 + } + + # Exercises the real loader, including the platform-package-then-debug + # fallback in src/load.cts. `require` alone proves nothing since the + # laziness change — the binding resolves on first use, not on import. + node -e "require('./lib/index.cjs').assertNativeBindingAvailable()" + echo "index.node loads" + + if [ "$WANT_WASM" = "true" ]; then + for f in dist/wasm/protect_ffi.js \ + dist/wasm/protect_ffi_inline.js \ + dist/wasm/protect_ffi_bg.wasm ; do + test -s "$f" || { + echo "::error::$f missing — the WASM build did not produce it" + exit 1 + } + done + node --input-type=module -e " + const m = await import('./dist/wasm/protect_ffi_inline.js') + if (typeof m.newClient !== 'function') { + throw new Error('wasm-inline resolved but exports no newClient') + } + " + echo "dist/wasm loads" + fi diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3bcb587ca..57a53d23c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,9 +10,17 @@ updates: timezone: Australia/Sydney # Wait for community discovery before opening PRs # (lirantal/npm-security-best-practices #2: install with cooldown). + # + # No `semver-major-days:`, and it is not an oversight. That key delays + # major VERSION update PRs, and the `ignore` below stops Dependabot + # proposing one at all; nor could it apply on the security path, because + # "the cooldown option is only available for version updates, not + # security updates" (Dependabot options reference). So it would be + # configuration that does nothing — the same reason the cargo entry + # below leaves `day:` out of its monthly schedule. Enforced by + # e2e/tests/supply-chain.e2e.test.ts, in both directions. cooldown: default-days: 7 - semver-major-days: 14 open-pull-requests-limit: 10 labels: - dependencies @@ -46,14 +54,6 @@ updates: # bug). Bump all seven catalog entries together, manually. Lockstep is # enforced by e2e/tests/supply-chain.e2e.test.ts. - dependency-name: "@cipherstash/auth-*" - # Release-managed manually alongside stack releases. Grouped bumps are - # actively harmful here: Dependabot's "group consistency" once upgraded - # the (since-removed) `protect` package off its 0.23.0 pin — 0.24+ renamed - # the exports it imported — while DOWNGRADING the stack packages from - # 0.29.0 (see #673). The surviving consumers (stack, stack-drizzle, - # stack-supabase) pin one version between them and must stay in lockstep, - # so the rule stands. - - dependency-name: "@cipherstash/protect-ffi" # 0.x bumps ship breaking type changes (e.g. 0.2 → 0.3 tightened the # FailureOption constraint). Review and apply manually. - dependency-name: "@byteslice/result" @@ -62,6 +62,93 @@ updates: update-types: - version-update:semver-major + # ── Cargo (packages/protect-ffi — the only Rust in the repo) ──── + # Absorbing protect-ffi brought a 494-crate Cargo.lock in-tree. osv-scanner + # already sees it — `--recursive ./` walks the tree and extracts every + # lockfile it recognises — so known advisories were visible from day one, but + # nothing proposed routine version updates. That is the gap this entry + # closes, and the only one it closes. Coverage is enforced by + # e2e/tests/supply-chain.e2e.test.ts, which derives the required ecosystems + # from the lockfiles actually present rather than from a list. + - package-ecosystem: cargo + # NOT `/`. The Cargo workspace root is the package directory — Cargo.toml + # and Cargo.lock live there, the crate itself under crates/protect-ffi. + # A misaimed directory fails silently: Dependabot records "no manifest + # found" on a log page nobody visits, and the symptom is simply that no PR + # ever arrives. The e2e test asserts this path holds a Cargo.toml. + directory: /packages/protect-ffi + # Monthly, where npm and actions are weekly. The deviation is about + # validation cost, not risk appetite: a Cargo.lock bump is checked by + # tests-rust.yml — one Blacksmith job, 45-minute timeout, cargo test + + # clippy on host AND wasm32 — and the release additionally cross-builds + # six platform binaries. Security fixes are not delayed by this: Dependabot + # security updates are driven by alerts, not by `schedule`. + # + # No `day:`, unlike the two weekly entries — that key picks a weekday for + # `interval: weekly` and has nothing to pick here. Left out rather than + # copied across, so it does not read as configuration that does nothing. + schedule: + interval: monthly + # Same community-discovery window as npm above and pnpm's + # minimumReleaseAge. crates.io has no unpublish, but it does have + # yanking, and a yank is the signal this window waits for. + # + # No `semver-major-days:` here either, for the reason recorded on the npm + # entry: majors are ignored below, and cooldown never reaches the security + # path, so a major window would have nothing to delay. + cooldown: + default-days: 7 + # Lower than npm's 10. Each open PR here costs a full native rebuild to + # validate, and the grouping below means one PR normally covers everything. + open-pull-requests-limit: 3 + labels: + - dependencies + - supply-chain + commit-message: + prefix: "chore" + include: scope + groups: + # One PR for the whole minor/patch sweep, as with actions below. Cargo + # resolves the lock as a unit, so per-crate PRs each re-resolve the + # others and conflict on every merge. + # + # Read the diff; do not rubber-stamp it. Dependabot classifies update + # types from the version numbers alone, so a 0.2 → 0.3 bump lands in this + # group as "minor" even though cargo treats it as breaking — several + # direct deps here are 0.x (wasm-bindgen, js-sys, web-sys, getrandom, + # serde-wasm-bindgen). The `ignore` on semver-major below does not catch + # those. Same trap the @byteslice/result note records on the npm side. + cargo-minor-patch: + patterns: + - "*" + update-types: + - minor + - patch + ignore: + # The CipherStash crates are pinned with EXACT `=` requirements in + # crates/protect-ffi/Cargo.toml and are the same release train as the + # @cipherstash/auth catalog entries above — cipherstash-client, + # cts-common, stack-auth and stack-profile all sit at =0.42.0, matching + # the catalog's 0.42.0. Dependabot bumping a subset is the Rust version + # of the rc.2 B1 bug recorded above: it would rewrite one `=` pin and + # leave the rest, and the crates do not tolerate skew. Bump them + # together, manually, in step with the npm catalog. + # + # Caveat worth knowing: `ignore` suppresses Dependabot SECURITY PRs too, + # not just version updates. osv-scanner is the compensating control — + # it scans this lockfile on every PR and on a weekday schedule, and + # surfaces crates.io advisories in code scanning. + - dependency-name: "cipherstash-client" + - dependency-name: "cts-common" + - dependency-name: "stack-auth" + - dependency-name: "stack-profile" + - dependency-name: "eql-bindings" + - dependency-name: "vitaminc" + # Major bumps are reviewed and applied manually, not by Dependabot. + - dependency-name: "*" + update-types: + - version-update:semver-major + # ── GitHub Actions ───────────────────────────────────────────── - package-ecosystem: github-actions directory: / diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index 7f642d26e..f4967a09e 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -119,7 +119,12 @@ jobs: # # Fork PRs have no secrets. Skip cleanly rather than fail on something the # contributor cannot fix — `tests.yml` still gives them a green signal. - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + # Every OTHER event runs, which is why this gates on "not a fork PR" rather + # than listing the event names allowed through: the listing form skipped + # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a + # declared manual trigger do nothing. Enforced by + # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} strategy: fail-fast: false diff --git a/.github/workflows/integration-prisma-next.yml b/.github/workflows/integration-prisma-next.yml index 35544513c..ddace2c34 100644 --- a/.github/workflows/integration-prisma-next.yml +++ b/.github/workflows/integration-prisma-next.yml @@ -65,7 +65,12 @@ jobs: # # Fork PRs have no secrets. Skip cleanly rather than fail on something the # contributor cannot fix — `tests.yml` still gives them a green signal. - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + # Every OTHER event runs, which is why this gates on "not a fork PR" rather + # than listing the event names allowed through: the listing form skipped + # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a + # declared manual trigger do nothing. Enforced by + # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} strategy: fail-fast: false diff --git a/.github/workflows/integration-protect-ffi.yml b/.github/workflows/integration-protect-ffi.yml new file mode 100644 index 000000000..fa9d1cd40 --- /dev/null +++ b/.github/workflows/integration-protect-ffi.yml @@ -0,0 +1,316 @@ +name: Integration — protect-ffi (native + WASM) + +# The `packages/protect-ffi/integration-tests/` suite: 19 files of live +# encrypt / decrypt / lock-context / keyset / JSON-SteVec / Postgres / +# WASM-round-trip coverage against real ZeroKMS and a real Postgres. +# +# WHY THIS EXISTS: upstream (`cipherstash/protectjs-ffi`) ran this suite on +# EVERY pull request from `.github/workflows/test.yml` — `mise setup`, then +# `mise run test:integration:all`. The absorption copied the suite in intact and +# left that workflow behind under `packages/protect-ffi/.github/`, where GitHub +# never looks: it reads workflows from the repository root alone. So from the +# day protect-ffi landed here, none of it ran, and a suite that never starts +# reads exactly like a suite that passes. +# `packages/protect-ffi/src/integrationSuiteCi.test.ts` asserts this file (or a +# successor) still invokes it. +# +# WHY THERE IS npm IN A pnpm REPO. `integration-tests/` is deliberately NOT a +# pnpm workspace member: it pins `@cipherstash/auth ^0.39.0`, `vitest ^3.1.3` +# and `@cipherstash/eql 3.0.2` independently of the repo catalog, which is part +# of what it tests — the suite proves the published FFI surface works for a +# consumer resolving its own dependency tree, not the one the monorepo happens +# to hoist. So it keeps its own `package-lock.json` and `mise run setup` installs +# it with `npm ci`. That is not a supply-chain regression: `npm ci` installs the +# lockfile exactly (it fails rather than resolving anything new) and verifies +# every tarball against the `integrity` hash recorded there, and the registry is +# npmjs. Absorbing the suite into the pnpm workspace is a follow-up that changes +# those pins, and changed pins can only be validated by a credentialed run. +# +# Separate from `tests.yml` on purpose, and separate from `tests-rust.yml`: this +# needs CipherStash credentials AND Docker, and the suites THROW rather than +# skip when unconfigured. Keeping them out of the unit job is what lets +# `pnpm test` stay runnable with neither. + +on: + push: + branches: [main] + paths: + # The suite itself: tests, fixtures, its compose file, its `tasks.toml` + # and its lockfile. + - 'packages/protect-ffi/integration-tests/**' + # The Rust the suite round-trips every payload through, and the manifests + # that change what cargo builds without touching a .rs file. Matching the + # filter in tests-rust.yml. + - 'packages/protect-ffi/crates/**' + - 'packages/protect-ffi/Cargo.toml' + - 'packages/protect-ffi/Cargo.lock' + # `src/**` is `lib/`, which is the module every test imports, plus + # `load.cts` — the loader that finds `index.node`. + - 'packages/protect-ffi/src/**' + # `scripts/inline-wasm.mjs` emits `dist/wasm/protect_ffi_inline.js`, which + # `tests/wasm-round-trip.test.ts` loads at module-graph time. An edit + # there breaks that suite while touching no Rust and no `src/`. + - 'packages/protect-ffi/scripts/**' + # package.json carries the build scripts and the exports map the suite + # resolves through; mise.toml carries the `tasks.toml` include, the + # toolchain pins, and the PG* connection env. + - 'packages/protect-ffi/package.json' + - 'packages/protect-ffi/mise.toml' + - '.github/workflows/integration-protect-ffi.yml' + - '.github/actions/build-ffi-binding/**' + - '.github/actions/require-cs-secrets/**' + pull_request: + branches: ['**'] + # Repeated verbatim: GitHub Actions does not support YAML anchors/aliases. + # A one-sided edit disables the job on pull requests while leaving it green + # on `main` — the exact inversion of what you want. + paths: + - 'packages/protect-ffi/integration-tests/**' + - 'packages/protect-ffi/crates/**' + - 'packages/protect-ffi/Cargo.toml' + - 'packages/protect-ffi/Cargo.lock' + - 'packages/protect-ffi/src/**' + - 'packages/protect-ffi/scripts/**' + - 'packages/protect-ffi/package.json' + - 'packages/protect-ffi/mise.toml' + - '.github/workflows/integration-protect-ffi.yml' + - '.github/actions/build-ffi-binding/**' + - '.github/actions/require-cs-secrets/**' + workflow_dispatch: {} + +permissions: + contents: read + +defaults: + run: + shell: bash + +# Ref-scoped, so the only thing it cancels is a superseded commit on the same +# branch — which is what you want for a job that holds live credentials and a +# database for tens of minutes. This is NOT the cross-workflow job-level group +# that `.github/actions/integration-db` was written to remove: that one was +# shared by three workflows, and GitHub cancels a third contender outright +# rather than queueing it, which dropped whole signals. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration: + name: protect-ffi integration suite + runs-on: blacksmith-4vcpu-ubuntu-2404 + # Generous, because a cold `build-ffi-binding` compiles the cipherstash + # client graph from scratch. It is also the backstop for `mise run + # start-db`, whose readiness loop retries `pg_isready` forever rather than + # giving up. + timeout-minutes: 45 + # Everything runs EXCEPT a fork PR, which has no secrets: skip that cleanly + # rather than fail on something the contributor cannot fix — `tests.yml` + # still gives them a green signal. + # + # Stated as "not a fork PR", NOT as a list of the events allowed to run. + # The previous form (`github.event_name == 'push' || `) + # made the `workflow_dispatch:` above decorative: on a manual dispatch the + # event name is neither `push` nor `pull_request`, and the payload carries + # no `pull_request` object to reach through, so both operands were false — + # the run was created, the only job was skipped, and the run reported + # success having executed nothing. This is the one credentialed suite with + # no other way to be re-run on demand: its `paths:` filter means an + # unrelated commit will not start it either. An allowlist of event names + # would break again the day someone adds `schedule:`. + # Enforced by scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} + + env: + # Same split as the other integration workflows: the workspace CRN and + # client id are repo `vars`, the two keys are `secrets`. + CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} + CS_CLIENT_ID: ${{ vars.CS_CLIENT_ID }} + CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} + CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + # + # CS_ZEROKMS_HOST / CS_CTS_HOST are deliberately NOT set. Upstream's + # workflow pinned both to `ap-southeast-2`, which was right for the + # workspace whose credentials it held and is not portable: the client + # derives both hosts from the workspace CRN, so hardcoding a region here + # would silently point this repo's shared credentials at the wrong + # deployment. No other credentialed workflow in this repo sets them. + # + # The PG* block is how `tests/postgres*.test.ts` reach the database: + # they construct `new Client()` with no arguments, so libpq's environment + # is the entire connection string. + # + # 5436, not 5432. `integration-tests/docker-compose.yml` publishes + # `5436:5432`, so 5436 is the only port that reaches the container from + # the runner. Upstream's workflow said 5432 and worked anyway because + # every invocation went through mise, and `[env]` in + # packages/protect-ffi/mise.toml (which says 5436) overrides an inherited + # value — verified, not assumed. The suite step below runs vitest + # directly, outside mise, so that safety net is gone and this block has to + # be right. The values here are identical to mise.toml's, which makes the + # override a no-op for the steps that do go through mise. + PGHOST: localhost + PGPORT: '5436' + PGDATABASE: cipherstash + PGUSER: cipherstash + PGPASSWORD: password + + steps: + # Not `./.github/actions/integration-setup`, and the difference is + # deliberate: that action builds the `stash` CLI because the stack + # integration harness installs EQL v3 by shelling out to it. This suite + # installs EQL from its own pinned `@cipherstash/eql` instead (see + # `eql:v3:install` in integration-tests/tasks.toml), so the CLI build + # would be minutes of runner time for nothing — and it pins Node 24, + # where this suite's `npm ci` is verified against the npm that ships with + # 22 (see the Node step below). + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v6.0.9 + with: + run_install: false + + # Node 22, not the 24 the other integration jobs use. `integration-tests` + # is installed with `npm ci` against a lockfile whose `".."` entry + # records an OLDER manifest of this very package (protect-ffi 0.29.0, and + # different devDependencies) — a link entry npm re-reads from disk. That + # resolves cleanly under npm 10, which ships with Node 22 and is what the + # lockfile was last verified against; npm 11's stricter + # manifest-vs-lockfile agreement check is an unforced risk for a job whose + # point is the encryption coverage, not the installer. + - uses: actions/setup-node@v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships + # node-gyp on PATH, so install it explicitly. + # + # Nothing in this job uses a pty. node-pty is a dev-dependency of the + # `stash` CLI and the repo's one entry in `pnpm.onlyBuiltDependencies`, so + # its install hook fires on every workspace install — and the 1.1.0 + # tarball ships prebuilds for darwin and win32 ONLY, making the fallback + # unconditional on a Linux runner. Without this step the job dies in + # `Install dependencies` with `sh: 1: node-gyp: not found`, before the + # secrets pre-flight, the binding build or a single test. + # Enforced by scripts/__tests__/workflow-node-gyp.test.mjs. + - name: Install node-gyp + run: npm install -g node-gyp + + # The workspace install, needed before the binding build: `build-ffi-binding` + # runs `pnpm --filter @cipherstash/protect-ffi run build`, i.e. the repo's + # tsc and neon. It is also what puts `@neon-rs/load` in + # packages/protect-ffi/node_modules — the suite reaches the package by + # symlink, so the parent's own dependencies come from pnpm, not from + # `npm ci`. + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Fast pre-flight: fail in seconds if a secret was rotated or cleared, + # before the binding build and the docker pull. Ordered ahead of anything + # expensive on purpose — a pre-flight that runs after a cold Rust build is + # not a pre-flight. Enforced by + # scripts/__tests__/ffi-binding-step-order.test.mjs. + - name: Require CipherStash secrets + uses: ./.github/actions/require-cs-secrets + with: + workspace-crn: ${{ vars.CS_WORKSPACE_CRN }} + client-id: ${{ vars.CS_CLIENT_ID }} + client-key: ${{ secrets.CS_CLIENT_KEY }} + client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + + # `wasm: 'true'` because `tests/wasm-round-trip.test.ts` and + # `tests/wasm-error-codes.test.ts` load `dist/wasm/protect_ffi_inline.js` + # from the built package, and both FAIL rather than skip when it is + # missing (they `existsSync` it and throw a build hint — the dynamic + # `import` is deliberate, so the artifact is needed by the time the suite + # runs rather than at collection). No other step in this job produces it. + # + # This replaces upstream's `mise run build:debug`, and the profile change + # is the point. The loader's fallback is `require('../index.node')` at the + # package root (see src/load.cts), which is exactly where this action's + # `neon dist` puts the RELEASE build — so a release binary satisfies the + # suite, and this action caches it on a content hash of the Rust inputs. + # Going through the mise task instead would recompile the client SDK in + # the debug profile on every run and overwrite the artifact the rest of + # CI already paid for. The action also proves both artifacts LOAD, so a + # restore that landed nothing fails here naming the file rather than as + # 19 unrelated encryption errors. + - name: Build the protect-ffi binding + uses: ./.github/actions/build-ffi-binding + with: + wasm: 'true' + + # `working_directory` is load-bearing, not tidiness. mise reads config + # from the current directory and its PARENTS, so an action running at the + # repo root never sees packages/protect-ffi/mise.toml — it would install + # nothing and leave the config untrusted, and the later `mise run` fails + # with "Config files ... are not trusted", which reads as a toolchain + # problem rather than a trust one. + # + # A bare `install: true`, unlike `.github/actions/build-ffi-binding` which + # narrows to wasm-pack with `install_args`. Narrowing would not help here: + # `mise run` auto-installs any declared tool that is missing (verified), + # so it would only move the zig / cargo-zigbuild build into the middle of + # `mise run setup` instead of removing it. Installing up front is what + # mise-action's cache can absorb. + # + # SHA-pinned rather than `@v3`, same pin as + # .github/actions/build-ffi-binding and .github/workflows/tests-rust.yml: + # mise-action is a third-party trust dependency the absorption introduced, + # the tag is mutable, and this job holds live CipherStash credentials. + # Dependabot moves the pin and the comment together. + - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + install: true + working_directory: packages/protect-ffi + + # One task, four things the suite cannot run without (see + # integration-tests/tasks.toml): `npm ci` for the suite's own dependency + # tree, `docker compose up` for Postgres, the EQL **v2** bundle from a + # GitHub release, and EQL **v3** from the pinned `@cipherstash/eql`. + # + # BOTH EQL versions are required and nothing else in this repo installs + # v2: `tests/postgres.test.ts` creates `eql_v2_encrypted` columns and + # calls `eql_v2.add_encrypted_constraint`, while + # `tests/postgres-v3.test.ts` needs the `eql_v3_*` domains. The repo's + # `.github/actions/integration-db` supplies neither — it brings up a + # compose stack and hands back a URL, leaving the schema to each suite's + # own setup (stack's harness shells out to `stash eql install`). + # + # That is also why this job does NOT use `integration-db`: the two EQL + # install tasks pipe SQL through `docker exec -i protect-ffi-postgres`, + # hardwired to the container name in the suite's own compose file, which + # `integration-db`'s per-job project names deliberately do not produce. + # The fixed host port (5436) that comes with it cannot collide with the + # shared stacks, which publish on ephemeral ports. + - name: Provision the suite (npm ci, Postgres, EQL v2 + v3) + working-directory: packages/protect-ffi + run: mise run setup + + # vitest directly rather than `mise run test:integration:all`, because + # that task's first two steps are the debug cargo build and a wasm-pack + # rebuild that `build-ffi-binding` has already done (above). What it runs + # after those is exactly this: `npx vitest`, the whole directory, INCLUDING + # `tests/lock-context.test.ts`. Its sibling `test:integration` excludes + # that file; upstream CI ran `:all` for a reason, and an invocation that + # inherits the exclusion silently drops the identity-aware coverage. + # + # `npx` resolves the vitest 3 pinned in integration-tests/package-lock.json, + # not the workspace catalog's — that pin is part of what the suite fixes. + - name: Integration suite (live ZeroKMS + Postgres, native and WASM) + working-directory: packages/protect-ffi/integration-tests + run: npx vitest run + + # `-v` drops the volume too. The runner is ephemeral, so this is not about + # reclaiming it: it is so a re-run on a warm runner cannot inherit a + # half-installed EQL schema and report a failure that only reproduces + # there. The default compose project name is the directory name, which is + # what `mise run start-db` brought the stack up under. + - name: Stop Postgres + if: always() + working-directory: packages/protect-ffi/integration-tests + run: docker compose down -v diff --git a/.github/workflows/integration-supabase.yml b/.github/workflows/integration-supabase.yml index 25857b159..34b1c3349 100644 --- a/.github/workflows/integration-supabase.yml +++ b/.github/workflows/integration-supabase.yml @@ -106,7 +106,12 @@ jobs: # # Fork PRs have no secrets. Skip cleanly rather than fail loudly on something # the contributor cannot fix — `tests.yml` still gives them a green signal. - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + # Every OTHER event runs, which is why this gates on "not a fork PR" rather + # than listing the event names allowed through: the listing form skipped + # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a + # declared manual trigger do nothing. Enforced by + # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} strategy: # A one-element matrix, not a cross-product: the Supabase adapter only ever diff --git a/.github/workflows/prisma-example-readme-e2e.yml b/.github/workflows/prisma-example-readme-e2e.yml index 5df2dc58f..f0fef5db6 100644 --- a/.github/workflows/prisma-example-readme-e2e.yml +++ b/.github/workflows/prisma-example-readme-e2e.yml @@ -33,7 +33,12 @@ jobs: # Skip cleanly on fork PRs where secrets aren't available. The # test's `describe.skipIf(!authConfigured)` would also skip, but # gating at the job level produces a clean "skipped" status. - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + # Every OTHER event runs, which is why this gates on "not a fork PR" rather + # than listing the event names allowed through: the listing form skipped + # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a + # declared manual trigger do nothing. Enforced by + # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} env: CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} diff --git a/.github/workflows/prisma-next-e2e.yml b/.github/workflows/prisma-next-e2e.yml index 74f4c9552..bfc8fee58 100644 --- a/.github/workflows/prisma-next-e2e.yml +++ b/.github/workflows/prisma-next-e2e.yml @@ -35,7 +35,12 @@ jobs: # global-setup hook in the suite hard-errors when `CS_WORKSPACE_CRN` # is unset; gating at the job level produces a clean "skipped" # status instead of a noisy failure. - if: ${{ github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository }} + # Every OTHER event runs, which is why this gates on "not a fork PR" rather + # than listing the event names allowed through: the listing form skipped + # the job on `workflow_dispatch` in integration-protect-ffi.yml, making a + # declared manual trigger do nothing. Enforced by + # scripts/__tests__/workflow-dispatch-job-conditions.test.mjs. + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} env: CS_WORKSPACE_CRN: ${{ vars.CS_WORKSPACE_CRN }} diff --git a/.github/workflows/tests-rust.yml b/.github/workflows/tests-rust.yml new file mode 100644 index 000000000..ec237655f --- /dev/null +++ b/.github/workflows/tests-rust.yml @@ -0,0 +1,114 @@ +name: Tests (Rust) + +# The Rust half of packages/protect-ffi. Path-filtered and separate from +# tests.yml because it is the only Rust in the repo. +# +# Since the absorption these checks ran NOWHERE. Phase 1 deliberately moved +# `cargo test` + `cargo fmt --check` behind `test:cargo` and clippy behind +# `mise run lint:rust`, to keep cargo off every contributor's default +# `pnpm test` — but no root workflow picked them back up. +# `packages/protect-ffi/src/lintWiring.test.ts` asserts the split from the +# manifest side; this is the other half, and that test now reads this file. +# +# The filter is broader than `crates/**`: dependency, feature and toolchain +# changes all alter what cargo builds without touching a .rs file. + +on: + pull_request: + paths: + - 'packages/protect-ffi/crates/**' + - 'packages/protect-ffi/Cargo.toml' + - 'packages/protect-ffi/Cargo.lock' + - 'packages/protect-ffi/mise.toml' + - 'packages/protect-ffi/package.json' + - '.github/workflows/tests-rust.yml' + push: + branches: [main] + workflow_dispatch: {} + +permissions: + contents: read + +defaults: + run: + shell: bash + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: cargo test + clippy + rustfmt + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # `working_directory` is load-bearing, not tidiness. mise reads config + # from the current directory and its PARENTS, so an action running at the + # repo root never sees packages/protect-ffi/mise.toml — it would install + # nothing and leave the config untrusted. A later `mise run` then fails + # with "Config files ... are not trusted", which reads as a toolchain + # problem rather than a trust one. + # + # Caching is allowed here: this workflow publishes nothing, so + # scripts/lint-no-workflow-caching.mjs does not cover it. That matters — + # `cargo:cargo-zigbuild` builds from source, and the cache makes it a + # one-off rather than a per-run cost. + # + # SHA-pinned rather than `@v3`: mise-action is a third-party trust + # dependency the absorption introduced, and the tag is mutable. Same pin + # as .github/actions/build-ffi-binding — keep the two in step. + # scripts/__tests__/ffi-binding-action.test.mjs asserts both. + - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3.6.3 + with: + install: true + working_directory: packages/protect-ffi + + # `--all-targets` in the clippy invocation means all target KINDS (lib, + # bins, tests, benches), not all platform targets — it lints the host and + # nothing else. wasm32 needs its own invocation, and it is the build that + # ships to edge runtimes with the least test coverage behind it. + - name: Add wasm32 target + run: rustup target add wasm32-unknown-unknown + + - uses: pnpm/action-setup@v6.0.9 + with: + run_install: false + + - uses: actions/setup-node@v6.5.0 + with: + node-version: 22 + cache: 'pnpm' + + # node-pty's install hook falls back to `node-gyp rebuild` when no + # linux-x64 prebuild matches. pnpm/action-setup v6 no longer ships + # node-gyp on PATH, so install it explicitly. + # + # This job compiles no JS and runs no pty — it needs `pnpm install` only + # to reach the `test:cargo` script. But node-pty is the repo's one entry + # in `pnpm.onlyBuiltDependencies`, its install hook fires on every + # workspace install, and the 1.1.0 tarball ships prebuilds for darwin and + # win32 ONLY. So the fallback is unconditional on a Linux runner, and + # without this step the job dies in `Install dependencies` with + # `sh: 1: node-gyp: not found` — before cargo is ever invoked. + # Enforced by scripts/__tests__/workflow-node-gyp.test.mjs. + - name: Install node-gyp + run: npm install -g node-gyp + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: cargo test + rustfmt + run: pnpm --filter @cipherstash/protect-ffi run test:cargo + + # `lint:rust` is the aggregate entry point, and running it by name rather + # than its arms is the point: an arm reachable only by name is an arm + # nobody runs (#145). lintWiring.test.ts asserts every `lint:rust:*` task + # is in its `depends` list. + - name: clippy (host + wasm32) + working-directory: packages/protect-ffi + run: mise run lint:rust diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 59a1fa64c..f394b73b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -98,6 +98,8 @@ jobs: # Fail loudly if any live-test credential is missing, so the v3 live # matrix (and every other `describeLive` suite) can't silently skip. + # Before the binding build below, not after: this costs seconds and that + # costs minutes on a cold cache. - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: @@ -106,6 +108,17 @@ jobs: client-key: ${{ secrets.CS_CLIENT_KEY }} client-access-key: ${{ secrets.CS_CLIENT_ACCESS_KEY }} + # Vendoring protect-ffi put it in `turbo test --filter './packages/*'`, + # so root `pnpm test` now runs ITS suite too — and + # `src/nativeLoading.test.ts` asserts the platform binary loads. Nothing + # else in this job produces `index.node`. The type steps below need + # `lib/` for the same reason: they call package scripts directly, so + # turbo's `^build` never runs and protect-ffi's declarations resolve to + # nothing. No `wasm: true` — every `wasm-inline` unit test either mocks + # the module or asserts on the bundle graph. + - name: Build the protect-ffi binding + uses: ./.github/actions/build-ffi-binding + - name: Type tests (stack) run: pnpm --filter @cipherstash/stack run test:types diff --git a/.gitignore b/.gitignore index bdf2c99c4..00af2bf73 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,28 @@ dist mise.local.toml .env +# Three generated WASM declaration files are tracked deliberately. They have to +# be re-included from HERE: git cannot re-include a file whose parent directory +# is excluded, and the bare `dist` above excludes the directory itself, so a +# negation in packages/protect-ffi/.gitignore has no effect on its own. +# +# Why they are tracked at all: `packages/stack` value-imports +# `@cipherstash/protect-ffi/wasm-inline`, so its declaration build cannot +# resolve the module unless these exist. Generating them needs Rust, the wasm32 +# target and wasm-pack — which would land on every contributor and every PR job +# purely to typecheck. Export maps resolve `types` and `default` independently, +# so declarations alone suffice; verified by deleting every `.js` and `.wasm` +# under dist/wasm and running stack's build, test:types:dist, test:types and +# unit suite green. The runtime output (7.4MB, regenerated each release) is +# still ignored. See packages/protect-ffi/.gitignore for the sibling rules. +!packages/protect-ffi/dist/ +packages/protect-ffi/dist/* +!packages/protect-ffi/dist/wasm/ +packages/protect-ffi/dist/wasm/* +!packages/protect-ffi/dist/wasm/protect_ffi.d.ts +!packages/protect-ffi/dist/wasm/protect_ffi_bg.wasm.d.ts +!packages/protect-ffi/dist/wasm/errors.d.ts + # dependencies /node_modules /.pnp diff --git a/AGENTS.md b/AGENTS.md index 3c64c93a7..a989e5158 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,11 +82,117 @@ If these variables are missing, tests that require live encryption will fail or - `packages/nextjs`: Next.js helpers and Clerk integration (`./clerk` export) - `packages/utils`: Shared config (`utils/config`) and logger (`utils/logger`) - `packages/bench`: Performance / index-engagement benchmarks (private, not published) +- `packages/protect-ffi`: Native FFI bindings to the CipherStash Client SDK (`@cipherstash/protect-ffi`) — the Rust core that `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-` and linked here via `workspace:*`. See the "Working on protect-ffi" notes below before touching it — its default `test` and `build` are deliberately Rust-free. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `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). - `skills/*`: Agent skills (`stash-cli`, `stash-encryption`, `stash-indexing`, `stash-deployment`, `stash-zerokms`, `stash-auth`, `stash-postgres`, `stash-edge`, `stash-drizzle`, `stash-dynamodb`, `stash-supabase`, `stash-prisma`, `stash-supply-chain-security`) +## Working on protect-ffi + +`packages/protect-ffi` is the only Rust in this repo, and its scripts are split +so that stays true for everyone else. + +- **The default `test` and `build` never invoke cargo.** Root `pnpm test` runs + `turbo test --filter './packages/*'`, which reaches this package — so a cargo + process on that path is a Rust toolchain on every contributor's machine. + `test` is the JS chain; `build` is `tsc`. +- **CI does build the binding, in the jobs that need it.** That is the limit of + the rule above: it keeps cargo off the *scripts*, not out of the pipeline. + Absorbing protect-ffi turned `lib/`, `index.node` and `dist/wasm/**` from + tarball contents into build outputs, so every job that encrypts, decrypts, or + typechecks against the package builds them first via + `.github/actions/build-ffi-binding` (pass `wasm: 'true'` for the two that + load the real WASM). The action caches `index.node` on a content hash of the + Rust inputs, so a PR touching no Rust pays a restore rather than a compile. + **A new job that runs live encryption needs this step** — without it the + failure is `Cannot find module '.../protect-ffi-linux-x64-gnu/index.node'`, + reported once per test rather than once per job. +- **Rust checks live behind `test:cargo`** (`cargo test` + `cargo fmt --check`) + and `mise run lint:rust` (clippy, host and wasm32). `build:native` carries + `cargo build --release`. +- **`src/lintWiring.test.ts` enforces the split**: no `test:*` script may be + unreachable from both entry points, nothing cargo may be reachable from + `test`, and every cargo check must be reachable from `test:cargo`. A check + nothing invokes reads exactly like a check that passes — that is why the file + exists. +- **Do not write `pnpm run