Skip to content

fix(stack): restore runtime null guards in encryption operations#493

Merged
coderdan merged 3 commits into
mainfrom
fix/restore-null-guards-in-encryption-ops
May 26, 2026
Merged

fix(stack): restore runtime null guards in encryption operations#493
coderdan merged 3 commits into
mainfrom
fix/restore-null-guards-in-encryption-ops

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

@coderdan coderdan commented May 26, 2026

Fixes #494.

Summary

Restores the if (value === null) return null guards that were stripped from every operation in packages/stack/src/encryption/operations/ by commit 5b7288b feat(stack): remove null from Encrypted type. That commit was meant as a type-level tightening but also deleted the matching runtime guards — so callers reaching an operation through a cast, dynamic model walking, or JS interop would silently get their null encrypted into a real SteVec ciphertext ({ k: 'sv', v: 2, ... }).

Discovered via the newly-ported round-trips null values integration test in #328, which calls encrypt(null) directly and asserts the round-trip preserves null. Without these guards, that test fails with:

AssertionError: expected { k: 'sv', v: 2, i: { …(2) }, …(1) } to be null

What changed

Mirrors the @cipherstash/protect pattern across all six operations:

Operation Restored guard
encrypt / *WithLockContext early return null on null plaintext
bulkEncrypt / *WithLockContext filter-null + position-preserving merge
decrypt / *WithLockContext early return null on null ciphertext
bulkDecrypt / *WithLockContext filter-null + position-preserving merge
encryptQuery / *WithLockContext return { data: null } for null/undefined
batchEncryptQuery / *WithLockContext per-element filter, position-stable result

Type adjustments so the restored guards compile and honestly reflect runtime:

  • BulkEncryptPayload['plaintext'], BulkEncryptedData['data'], BulkDecryptPayload['data'], and the T of BulkDecryptedData widen to ... | null. Bulk APIs now accept and return mixed nullable arrays without ahead-of-time filtering.
  • EncryptedQueryResult widens to include null for batch-path position-stability.
  • Encryption.decrypt() accepts Encrypted | null.
  • Encryption.encrypt() public signature is unchanged (still JsPlaintext). The runtime guard is defense in depth.

Test plan

  • CI green on this branch
  • Rebase test: port missing searchable JSON tests to stack package #328 on top of this branch and verify the re-enabled round-trips null values test passes
  • Verify no regressions in __tests__/nested-models.test.ts and __tests__/bulk-protect.test.ts (these exercise null handling through the model layer)

Summary by CodeRabbit

  • Bug Fixes

    • Restored runtime null/undefined short-circuiting so null values are not encrypted as ciphertext
    • Bulk encrypt/decrypt preserve original item positions and return per-item nulls for null inputs
    • Query encryption returns null for null/undefined terms instead of attempting encryption
    • Decrypt operations now gracefully return null for null inputs
  • Tests

    • Added tests validating null-handling and position-stable results for bulk/batch operations

Review Change Stack

Commit 5b7288b (feat(stack): remove null from Encrypted type) tightened
the public types to disallow null in single-value encrypt/decrypt, and
deleted the matching runtime `if (value === null) return null` guards
across every operation in packages/stack/src/encryption/operations/.

The type narrowing does not survive runtime. Callers that reach an
operation through a cast (e.g. `null as any`), dynamic model field
walking, or JS interop will silently have their null encrypted by
protect-ffi into a real SteVec ciphertext (`{ k: 'sv', v: 2, ... }`),
breaking symmetry with the model-helpers layer where null is treated as
"absent" at the field level. The newly-ported searchable-json `round-
trips null values` test in PR #328 exercises this path directly and
surfaced the regression.

Restored guards mirror the @cipherstash/protect pattern:

- encrypt / EncryptOperationWithLockContext: early return null
- bulkEncrypt / *WithLockContext: filter-null + position-preserving merge
- decrypt / *WithLockContext: early return null
- bulkDecrypt / *WithLockContext: filter-null + position-preserving merge
- encryptQuery / *WithLockContext: return { data: null } for null/undefined
- batchEncryptQuery / *WithLockContext: per-element filter, position-stable

Internal operation field/return types widen to `T | null` so the
restored guards compile. Public bulk types (`BulkEncryptPayload`,
`BulkEncryptedData`, `BulkDecryptPayload`, `BulkDecryptedData`) and
`EncryptedQueryResult` widen to admit null in element positions — these
now honestly reflect runtime behavior and let callers process mixed
nullable arrays without filtering ahead of time. `Encryption.decrypt()`
accepts `Encrypted | null`. `Encryption.encrypt()`'s public signature
stays narrow (`JsPlaintext`); the runtime guard is defense in depth.
@coderdan coderdan requested a review from a team as a code owner May 26, 2026 04:05
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 26, 2026

🦋 Changeset detected

Latest commit: 0e778c4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@cipherstash/stack Minor
@cipherstash/bench Patch
@cipherstash/prisma-next Patch
@cipherstash/basic-example Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 39e8df89-31c1-4ca2-9e09-d6c794a7e010

📥 Commits

Reviewing files that changed from the base of the PR and between f0ccd7b and 0e778c4.

📒 Files selected for processing (1)
  • .changeset/restore-null-guards-in-encryption-ops.md
✅ Files skipped from review due to trivial changes (1)
  • .changeset/restore-null-guards-in-encryption-ops.md

📝 Walkthrough

Walkthrough

Restores runtime null short-circuiting across encrypt/decrypt, bulk, and batch query operations; widens relevant bulk/query types to accept null; bulk/batch flows filter null inputs before FFI calls and reconstruct outputs to preserve original input positions.

Changes

Null-Safe Encryption Operations

Layer / File(s) Summary
Type contract updates for nullable encryption
packages/stack/src/types.ts
EncryptedQueryResult, BulkEncryptPayload, BulkEncryptedData, BulkDecryptPayload, and BulkDecryptedData are widened to include null in plaintext, ciphertext, and decrypted result positions.
Encrypt operation with null guards
packages/stack/src/encryption/operations/encrypt.ts
EncryptOperation and EncryptOperationWithLockContext store nullable plaintext, short-circuit execute() to return null when plaintext is null, and expose nullable plaintext in getOperation().
Decrypt operation with null guards
packages/stack/src/encryption/operations/decrypt.ts
DecryptOperation and DecryptOperationWithLockContext accept nullable encrypted input, short-circuit execute() to return null when encryptedData is null, and expose nullable encryptedData in getOperation().
Query encrypt operation with null handling
packages/stack/src/encryption/operations/encrypt-query.ts
EncryptQueryOperation and lock-context variant accept `JsPlaintext
Batch query encrypt with filtering and result assembly
packages/stack/src/encryption/operations/batch-encrypt-query.ts
Adds filterNullTerms to collect non-null terms with original indices, build FFI payloads from non-null terms only, and assembleResults to reinsert null slots preserving input order; lock-context path skips lock acquisition when all terms are null.
Bulk encrypt with null filtering and reconstruction
packages/stack/src/encryption/operations/bulk-encrypt.ts
Filter null plaintexts before encryptBulk, return an all-null result when applicable, and remap encrypted outputs back into original positions via reconstruction helpers.
Bulk decrypt with null filtering and reconstruction
packages/stack/src/encryption/operations/bulk-decrypt.ts
Filter null ciphertexts before decryptBulkFallible, return all-null results when applicable, and remap decrypted results/errors to original positions using a separate decrypted index.
Encryption API JSDoc update
packages/stack/src/encryption/index.ts
Adds remarks documenting defensive runtime null short-circuiting despite narrow public input types.
Changeset
.changeset/restore-null-guards-in-encryption-ops.md
Changeset describing restored runtime null guards, widened bulk/query types, and preserved public API boundary.
Tests
packages/stack/__tests__/null-guards.test.ts
Adds tests that construct operations with null/undefined inputs to assert short-circuiting, no FFI calls, and position-preserving outputs for bulk/batch arrays.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • calvinbrewer
  • auxesis
  • tobyhede

Poem

🐰 I hopped through code where nulls once slipped,

Guards returned, and ciphertext was skipped,
Arrays keep places, each slot stays true,
Filters and maps stitch the gaps anew,
A rabbit cheers — nulls now safely skip the crypt.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(stack): restore runtime null guards in encryption operations' clearly and specifically describes the main change: restoring null guards across encryption operation classes.
Linked Issues check ✅ Passed The PR fully implements all coding requirements from issue #494: runtime null guards in all six operation groups (encrypt, bulkEncrypt, decrypt, bulkDecrypt, encryptQuery, batchEncryptQuery), position-preserving filters for bulk/batch, type widening for bulk payloads and EncryptedQueryResult, narrow public API signatures, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes directly address null guard restoration and required type adjustments; no unrelated modifications detected beyond the scope of issue #494.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/restore-null-guards-in-encryption-ops

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderdan
Copy link
Copy Markdown
Contributor Author

Surfaced by #328, which ports a round-trips null values integration test that calls encrypt(null) directly and asserts the round-trip preserves null. With the guards stripped, that test fails with the SteVec-wrapped null. #328 is rebased on this branch and re-enables the test.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/stack/src/encryption/operations/bulk-encrypt.ts (1)

189-203: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the all-null short-circuit before lock-context retrieval.

Line 189 fetches lock context before the null-only check at Line 201. For all-null payloads, this can fail or add latency even though no encryption call is needed.

Suggested fix
-        const context = await this.lockContext.getLockContext()
-        if (context.failure) {
-          throw new Error(`[encryption]: ${context.failure.message}`)
-        }
-
-        const nonNullPayloads = createEncryptPayloads(
+        const nonNullPayloads = createEncryptPayloads(
           plaintexts,
           column,
           table,
-          context.data.context,
         )
 
         if (nonNullPayloads.length === 0) {
           return createNullResult(plaintexts)
         }
+
+        const context = await this.lockContext.getLockContext()
+        if (context.failure) {
+          throw new Error(`[encryption]: ${context.failure.message}`)
+        }
+
+        const payloadsWithContext = nonNullPayloads.map((p) => ({
+          ...p,
+          lockContext: context.data.context,
+        }))
 
         const { metadata } = this.getAuditData()
 
         const encryptedData = await encryptBulk(client, {
-          plaintexts: nonNullPayloads,
+          plaintexts: payloadsWithContext,
           serviceToken: context.data.ctsToken,
           unverifiedContext: metadata,
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/encryption/operations/bulk-encrypt.ts` around lines 189 -
203, The code calls this.lockContext.getLockContext() before checking for an
all-null short-circuit; move the null-only check so we avoid acquiring the
lock/context when unnecessary. Specifically, call
createEncryptPayloads(plaintexts, column, table, ...) or otherwise determine
nonNullPayloads from plaintexts first (using the same logic that
createEncryptPayloads uses) and if nonNullPayloads.length === 0 return
createNullResult(plaintexts) before invoking lockContext.getLockContext(); only
after that short-circuit acquire the lock via this.lockContext.getLockContext()
and then proceed to use context.data.context for encryption.
packages/stack/src/encryption/operations/bulk-decrypt.ts (1)

155-167: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Short-circuit null-only decrypt payloads before lock-context fetch.

Line 155 retrieves lock context before the all-null guard at Line 165. Null-only inputs should return immediately and not depend on lock-context availability.

Suggested fix
-        const context = await this.lockContext.getLockContext()
-        if (context.failure) {
-          throw new Error(`[encryption]: ${context.failure.message}`)
-        }
-
         const nonNullPayloads = createDecryptPayloads(
-          encryptedPayloads,
-          context.data.context,
+          encryptedPayloads,
         )
 
         if (nonNullPayloads.length === 0) {
           return createNullResult(encryptedPayloads)
         }
+
+        const context = await this.lockContext.getLockContext()
+        if (context.failure) {
+          throw new Error(`[encryption]: ${context.failure.message}`)
+        }
+
+        const payloadsWithContext = nonNullPayloads.map((p) => ({
+          ...p,
+          lockContext: context.data.context,
+        }))
 
         const { metadata } = this.getAuditData()
 
         const decryptedData = await decryptBulkFallible(client, {
-          ciphertexts: nonNullPayloads,
+          ciphertexts: payloadsWithContext,
           serviceToken: context.data.ctsToken,
           unverifiedContext: metadata,
         })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/encryption/operations/bulk-decrypt.ts` around lines 155 -
167, The code fetches lock context before short-circuiting null-only inputs;
move the early-return so null-only decrypts do not call
lockContext.getLockContext(): first detect if the incoming encryptedPayloads are
all "null" decrypts (use a simple predicate over encryptedPayloads to determine
null-only), if so immediately return createNullResult(encryptedPayloads), and
only after that call this.lockContext.getLockContext() and then invoke
createDecryptPayloads(encryptedPayloads, context.data.context) for non-null
work; reference symbols: lockContext.getLockContext, createDecryptPayloads,
createNullResult.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/stack/src/encryption/operations/bulk-decrypt.ts`:
- Around line 155-167: The code fetches lock context before short-circuiting
null-only inputs; move the early-return so null-only decrypts do not call
lockContext.getLockContext(): first detect if the incoming encryptedPayloads are
all "null" decrypts (use a simple predicate over encryptedPayloads to determine
null-only), if so immediately return createNullResult(encryptedPayloads), and
only after that call this.lockContext.getLockContext() and then invoke
createDecryptPayloads(encryptedPayloads, context.data.context) for non-null
work; reference symbols: lockContext.getLockContext, createDecryptPayloads,
createNullResult.

In `@packages/stack/src/encryption/operations/bulk-encrypt.ts`:
- Around line 189-203: The code calls this.lockContext.getLockContext() before
checking for an all-null short-circuit; move the null-only check so we avoid
acquiring the lock/context when unnecessary. Specifically, call
createEncryptPayloads(plaintexts, column, table, ...) or otherwise determine
nonNullPayloads from plaintexts first (using the same logic that
createEncryptPayloads uses) and if nonNullPayloads.length === 0 return
createNullResult(plaintexts) before invoking lockContext.getLockContext(); only
after that short-circuit acquire the lock via this.lockContext.getLockContext()
and then proceed to use context.data.context for encryption.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b74b156c-8649-4fcb-96dd-3456d39ce5c5

📥 Commits

Reviewing files that changed from the base of the PR and between a087730 and 712d7fa.

📒 Files selected for processing (9)
  • .changeset/restore-null-guards-in-encryption-ops.md
  • packages/stack/src/encryption/index.ts
  • packages/stack/src/encryption/operations/batch-encrypt-query.ts
  • packages/stack/src/encryption/operations/bulk-decrypt.ts
  • packages/stack/src/encryption/operations/bulk-encrypt.ts
  • packages/stack/src/encryption/operations/decrypt.ts
  • packages/stack/src/encryption/operations/encrypt-query.ts
  • packages/stack/src/encryption/operations/encrypt.ts
  • packages/stack/src/types.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR restores runtime null short-circuits across @cipherstash/stack encryption operations so null inputs do not reach @cipherstash/protect-ffi (which would otherwise encrypt JSON null into a real ciphertext), and updates types to reflect the null-preserving behavior.

Changes:

  • Reintroduced runtime null/undefined guards for single encrypt/decrypt and query-encrypt operations.
  • Updated bulk and batch operations to filter nullish inputs, call the FFI only for non-null elements, and then reassemble position-stable outputs with null slots preserved.
  • Widened relevant public types (bulk payload/results, query result) and added a changeset entry.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
packages/stack/src/types.ts Widens bulk/query result types to include null for position-stable null preservation.
packages/stack/src/encryption/operations/encrypt.ts Adds runtime guard to short-circuit null plaintexts before FFI encryption.
packages/stack/src/encryption/operations/decrypt.ts Adds runtime guard to short-circuit null ciphertexts before FFI decryption.
packages/stack/src/encryption/operations/bulk-encrypt.ts Filters null plaintexts out of the FFI call and re-inserts null in the output at original indices.
packages/stack/src/encryption/operations/bulk-decrypt.ts Filters null ciphertexts out of the FFI call and re-inserts null in the output at original indices.
packages/stack/src/encryption/operations/encrypt-query.ts Returns { data: null } for nullish plaintexts and adjusts typings to allow nullish input.
packages/stack/src/encryption/operations/batch-encrypt-query.ts Filters nullish query terms and reconstructs a position-stable results array with null slots.
packages/stack/src/encryption/index.ts Widens decrypt() to accept `Encrypted
.changeset/restore-null-guards-in-encryption-ops.md Adds a patch changeset describing the null-guard restoration and type changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/stack/src/encryption/operations/encrypt.ts Outdated
Comment thread packages/stack/src/encryption/operations/encrypt.ts
Comment thread packages/stack/src/encryption/operations/decrypt.ts Outdated
Comment thread packages/stack/src/encryption/operations/encrypt-query.ts Outdated
Comment thread packages/stack/src/encryption/operations/encrypt-query.ts
Comment thread packages/stack/src/encryption/operations/bulk-encrypt.ts Outdated
Comment thread packages/stack/src/encryption/index.ts
- Keep public encrypt() / decrypt() return types narrow. The runtime
  guards now cast `null as unknown as Encrypted` / `JsPlaintext` at the
  short-circuit site so callers who respect the input contract get a
  non-nullable `data` field as before. The cast acknowledges that the
  null branch is unreachable by the public type — defense in depth only
  trips on direct construction with a cast plaintext / ciphertext.
- Public `Encryption.decrypt()` reverts to `decrypt(encryptedData:
  Encrypted)` (was widened to `Encrypted | null`). JSDoc gets a
  `@remarks` block documenting the runtime null short-circuit so the
  defense-in-depth behavior is discoverable.
- encrypt-query operations no longer carry a redundant `| null` in
  their generic / execute return — `EncryptedQueryResult` already
  includes null, so the union was double-nullable.
- encrypt-query: capture a local `const plaintext: JsPlaintext` after
  the null/undefined guard so the three `as JsPlaintext` casts inside
  withResult drop. Same in the WithLockContext variant.
- bulk-encrypt: consolidate the two `@/types` import statements.
- Add `__tests__/null-guards.test.ts` covering encrypt(null),
  decrypt(null), bulkEncrypt all-null, bulkDecrypt all-null,
  encryptQuery(null|undefined), and batchEncryptQuery all-null/undefined.
  Constructs operation classes directly so the test runs without
  credentials — the short-circuits return before any FFI call.
@coderdan
Copy link
Copy Markdown
Contributor Author

Addressed Copilot's review in f0ccd7b:

  1. encrypt.ts:26 — type-breaking widening of EncryptOperation return. Reverted: EncryptOperation extends EncryptionOperation<Encrypted> again (non-nullable). The runtime guard now casts null as unknown as Encrypted at the short-circuit site. Public Encryption.encrypt() callers respecting the input contract get the same non-nullable data they had before.
  2. encrypt.ts:66 — no test for the restored guard. Added __tests__/null-guards.test.ts with seven focused cases: encrypt(null), decrypt(null), bulkEncrypt all-null, bulkDecrypt all-null, encryptQuery(null) and (undefined), and batchEncryptQuery all-null/undefined. Constructs the operation classes directly so the tests run without credentials — the short-circuits return before any FFI call.
  3. decrypt.ts:24 — same breaking widening on DecryptOperation. Reverted symmetrically. DecryptOperation extends EncryptionOperation<JsPlaintext> again; Encryption.decrypt(encryptedData: Encrypted) reverts to the narrow public signature.
  4. encrypt-query.ts:25 — redundant | null. Dropped. EncryptedQueryResult already includes null, so EncryptedQueryResult | null was double-nullable.
  5. encrypt-query.ts:90 — repeated as JsPlaintext casts. Captured const plaintext: JsPlaintext = this.plaintext after the null/undefined guard, dropped the three casts inside withResult. Same in the WithLockContext variant.
  6. bulk-encrypt.ts:17 — duplicate @/types import. Consolidated into one import block.
  7. encryption/index.ts:315 — JSDoc on decrypt(). Added a @remarks block documenting the runtime null short-circuit so the defense-in-depth behavior is discoverable from the published docs.

PR #328 rebased on the updated fix branch.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
packages/stack/src/encryption/operations/bulk-encrypt.ts (1)

189-203: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

All-null lock-context path still performs context retrieval before short-circuit.

At Line 189, getLockContext() is called before the all-null check at Line 201. For all-null payloads, this can fail on context lookup instead of returning { id, data: null } placeholders.

💡 Proposed fix
-        const context = await this.lockContext.getLockContext()
-        if (context.failure) {
-          throw new Error(`[encryption]: ${context.failure.message}`)
-        }
-
-        const nonNullPayloads = createEncryptPayloads(
-          plaintexts,
-          column,
-          table,
-          context.data.context,
-        )
-
-        if (nonNullPayloads.length === 0) {
+        const hasNonNullPlaintext = plaintexts.some(
+          ({ plaintext }) => plaintext !== null,
+        )
+        if (!hasNonNullPlaintext) {
           return createNullResult(plaintexts)
         }
+
+        const context = await this.lockContext.getLockContext()
+        if (context.failure) {
+          throw new Error(`[encryption]: ${context.failure.message}`)
+        }
+
+        const nonNullPayloads = createEncryptPayloads(
+          plaintexts,
+          column,
+          table,
+          context.data.context,
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/encryption/operations/bulk-encrypt.ts` around lines 189 -
203, The code calls this.lockContext.getLockContext() before checking for an
all-null case, which can cause unnecessary context lookup failures; change the
order so you first call createEncryptPayloads(plaintexts, column, table, ...) to
compute nonNullPayloads and if nonNullPayloads.length === 0 immediately return
createNullResult(plaintexts) without calling this.lockContext.getLockContext();
only when nonNullPayloads is non-empty call this.lockContext.getLockContext(),
check context.failure and proceed using context.data.context for the encryption
flow.
packages/stack/src/encryption/operations/encrypt.ts (1)

138-173: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep numeric validation consistent in the lock-context path.

EncryptOperation.execute() still rejects NaN/Infinity on Lines 73-85, but this branch only short-circuits null and then goes straight to ffiEncrypt. That makes encrypt(x) and encrypt(x).withLockContext(...) behave differently for the same invalid numeric input.

Suggested fix
       async () => {
         if (!client) {
           throw noClientError()
         }

         if (plaintext === null) {
           return null as unknown as Encrypted
         }
+
+        if (typeof plaintext === 'number' && Number.isNaN(plaintext)) {
+          throw new Error('[encryption]: Cannot encrypt NaN value')
+        }
+
+        if (typeof plaintext === 'number' && !Number.isFinite(plaintext)) {
+          throw new Error('[encryption]: Cannot encrypt Infinity value')
+        }

         const { metadata } = this.getAuditData()
         const context = await this.lockContext.getLockContext()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/src/encryption/operations/encrypt.ts` around lines 138 - 173,
EncryptOperation.execute() is missing the numeric validation for NaN/Infinity in
the lock-context branch, causing different behavior between the short-circuit
path and the path that calls lockContext and ffiEncrypt; before calling
ffiEncrypt (after obtaining context.data and metadata) validate the plaintext
the same way the non-lock path does (reject NaN and Infinity) and throw the same
error/Result as the other branch so encrypt(x) and
encrypt(x).withLockContext(...) behave identically; update the logic in
EncryptOperation.execute() just prior to calling ffiEncrypt to perform that
numeric check on plaintext and surface the same error.
🧹 Nitpick comments (1)
packages/stack/__tests__/null-guards.test.ts (1)

42-53: ⚡ Quick win

Add a withLockContext() all-null short-circuit test to guard this regression.

This suite validates non-lock bulk null handling, but it doesn’t cover the lock-context variant. A dedicated all-null withLockContext() case would catch the Line 189 ordering issue in packages/stack/src/encryption/operations/bulk-encrypt.ts.

✅ Suggested test shape
+  it('bulkEncrypt.withLockContext all-null short-circuits before context lookup', async () => {
+    const op = new BulkEncryptOperation(
+      stubClient,
+      [{ id: 'a', plaintext: null }],
+      { column: table.metadata, table },
+    ).withLockContext({
+      getLockContext: async () => {
+        throw new Error('should not be called')
+      },
+    } as any)
+
+    const result = await op.execute()
+    if (result.failure) throw new Error(result.failure.message)
+    expect(result.data).toEqual([{ id: 'a', data: null }])
+  })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stack/__tests__/null-guards.test.ts` around lines 42 - 53, Add a new
unit test that mirrors the existing all-null fast-path case but invokes the
lock-context variant by calling withLockContext() on the BulkEncryptOperation so
the all-null short-circuit is exercised under lock; specifically, construct a
BulkEncryptOperation with [{id: 'a', plaintext: null}], call
operation.withLockContext() before execute(), assert no FFI call is made and the
result.data equals [{id: 'a', data: null}], and ensure the test targets the
behavior in bulk-encrypt.ts (BulkEncryptOperation.execute / withLockContext) to
catch the ordering/regression described.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/stack/src/encryption/operations/bulk-encrypt.ts`:
- Around line 189-203: The code calls this.lockContext.getLockContext() before
checking for an all-null case, which can cause unnecessary context lookup
failures; change the order so you first call createEncryptPayloads(plaintexts,
column, table, ...) to compute nonNullPayloads and if nonNullPayloads.length ===
0 immediately return createNullResult(plaintexts) without calling
this.lockContext.getLockContext(); only when nonNullPayloads is non-empty call
this.lockContext.getLockContext(), check context.failure and proceed using
context.data.context for the encryption flow.

In `@packages/stack/src/encryption/operations/encrypt.ts`:
- Around line 138-173: EncryptOperation.execute() is missing the numeric
validation for NaN/Infinity in the lock-context branch, causing different
behavior between the short-circuit path and the path that calls lockContext and
ffiEncrypt; before calling ffiEncrypt (after obtaining context.data and
metadata) validate the plaintext the same way the non-lock path does (reject NaN
and Infinity) and throw the same error/Result as the other branch so encrypt(x)
and encrypt(x).withLockContext(...) behave identically; update the logic in
EncryptOperation.execute() just prior to calling ffiEncrypt to perform that
numeric check on plaintext and surface the same error.

---

Nitpick comments:
In `@packages/stack/__tests__/null-guards.test.ts`:
- Around line 42-53: Add a new unit test that mirrors the existing all-null
fast-path case but invokes the lock-context variant by calling withLockContext()
on the BulkEncryptOperation so the all-null short-circuit is exercised under
lock; specifically, construct a BulkEncryptOperation with [{id: 'a', plaintext:
null}], call operation.withLockContext() before execute(), assert no FFI call is
made and the result.data equals [{id: 'a', data: null}], and ensure the test
targets the behavior in bulk-encrypt.ts (BulkEncryptOperation.execute /
withLockContext) to catch the ordering/regression described.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3a639a3a-f122-4358-8594-0647b36447e1

📥 Commits

Reviewing files that changed from the base of the PR and between 712d7fa and f0ccd7b.

📒 Files selected for processing (7)
  • .changeset/restore-null-guards-in-encryption-ops.md
  • packages/stack/__tests__/null-guards.test.ts
  • packages/stack/src/encryption/index.ts
  • packages/stack/src/encryption/operations/bulk-encrypt.ts
  • packages/stack/src/encryption/operations/decrypt.ts
  • packages/stack/src/encryption/operations/encrypt-query.ts
  • packages/stack/src/encryption/operations/encrypt.ts
✅ Files skipped from review due to trivial changes (2)
  • packages/stack/src/encryption/index.ts
  • .changeset/restore-null-guards-in-encryption-ops.md

Copy link
Copy Markdown
Contributor

@auxesis auxesis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you for fixing this @coderdan.

Have one question about whether this should be a patch or minor release, but no strong feelings either way.

Comment thread .changeset/restore-null-guards-in-encryption-ops.md Outdated
Per Lindsay's review on #493: the public type signatures shifted
(BulkEncryptPayload, BulkEncryptedData, BulkDecryptPayload,
BulkDecryptedData, and EncryptedQueryResult all admit null in element
positions, where they used to be narrow). Patch undersells that for SDK
consumers — bumping to minor.
@coderdan coderdan merged commit 06e3bd5 into main May 26, 2026
6 checks passed
@coderdan coderdan deleted the fix/restore-null-guards-in-encryption-ops branch May 26, 2026 05:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Runtime null guards stripped from stack encryption operations cause silent ciphertext for null input

3 participants