Skip to content

Commit e3580b2

Browse files
committed
fix(stack): address review — native FFI leak, Result plumbing, batch bugs
Review found 15 issues. This commit takes the source and test ones. CRITICAL (1/15) — the entry pulled in the NATIVE protect-ffi. `@/encryption/helpers/error-code` value-imports `ProtectError` for an `instanceof` narrow, and protect-ffi is not in tsup `noExternal`, so `dist/wasm-inline.js` carried a bare `@cipherstash/protect-ffi` import — the NAPI entry, in the one bundle that exists to avoid it. On Workers / Edge the non-`node` condition resolves it to a module exporting no `ProtectError`; under Deno it resolves to the NAPI loader. I had previously dismissed this as pre-existing. That was wrong: I ran `git stash -u` on a clean tree, so it stashed nothing and I rebuilt my own branch and compared it against itself. Re-verified properly by swapping in main's `wasm-inline.ts` and rebuilding: main emits only the `/wasm-inline` specifier, mine emitted both. Replaced with a structural `readErrorCode` — which is the only thing that could ever work here anyway, since the WASM build ships no error class for `instanceof` to match. GH #744 is filed on my faulty premise and needs correcting. New `wasm-inline-bundle-isolation.test.ts` asserts the built bundle's external imports, so this class of regression fails CI. Verified it catches the exact regression by reintroducing it (2 of 3 assertions fail), rather than assuming. 3/15 — `failure.code` could never be populated: `withResult` runs `onException` first, so the mapper only ever saw a fresh Error. `toError` now carries a structural `code` onto the synthesized Error, and `bulkDecrypt` puts each item's FFI code in its per-index message (a batch has no single code, but dropping it lost the only machine-readable part). 9/15 — `toError`'s `String(ex)` fallback could itself throw on a null-prototype object, escaping the Result contract entirely and rejecting the call. Guarded with `safeString`. 10/15 — sparse inputs. `items.map(() => null)` SKIPS holes, so a hole came back `undefined` rather than the documented `null` while lengths still matched, which the length guard cannot see. Now `Array.from`. 15/15 — the positional-batch scaffolding was hand-rolled three times, so a fourth batch method could omit `assertBatchLength` with no build failure. One `runBatch` helper owns compaction, short-circuit, length assert and scatter; the sparse fix lives there once. 5/15, 6/15, 7/15 — docs and surface: removed a duplicate stale `@returns`, migrated the module-header and all five `encryptQuery` examples off the pre-Result contract (they interpolated the envelope straight into SQL), corrected the pending `.changeset/wasm-encrypt-query.md` sentence that still said errors throw, and exported `EncryptionError` / `EncryptionErrorTypes` so an edge consumer can discriminate `failure.type` from the same import. `WasmResult<T>` is now declared locally rather than re-exporting `@byteslice/result`'s. Re-exporting put that specifier in the emitted `.d.ts`, which Deno cannot resolve — the same reason `WasmPlaintext` re-declares protect-ffi's `JsPlaintext`. 14/15 — test gaps: falsy-but-live values (`0` / `''` / `false`) per batch method, the sparse-hole case, `encryptQueryBulk`'s previously untested length guard, and the two stale "throws" titles. 840 tests pass. Zero new tsc errors, diffed against main's error set. 0 biome errors. Bundle verified free of the native FFI.
1 parent 93a83a3 commit e3580b2

8 files changed

Lines changed: 577 additions & 173 deletions

File tree

.changeset/wasm-encrypt-query.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ free-text match, ORE range, and JSON containment/selector — with the same
1414
index-type resolution as the native client (explicit `queryType`, or
1515
inference from the column's configured indexes). Cast the term to the
1616
column's `eql_v3.query_<domain>` type in SQL to reach the indexed operators.
17-
Errors throw, consistent with the WASM surface's `encrypt`/`decrypt`; the
18-
bulk form is position-stable (`null` values pass through as `null`).
17+
Both return `{ data } | { failure }`, consistent with the WASM surface's
18+
`encrypt`/`decrypt`; the bulk form is position-stable (`null` values pass
19+
through as `null`).

e2e/wasm/query-types.test.ts

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ const catalog = encryptedTable('wasm_query_matrix', {
7272
})
7373

7474
/** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */
75+
/**
76+
* Unwrap a `{ data } | { failure }` Result (#741 — every fallible method on
77+
* this entry returns one). Asserting on the envelope instead of its payload
78+
* silently passes: `term.v` is `undefined` on a Result, so a shape check reads
79+
* as "not v3" rather than "you forgot to unwrap".
80+
*/
81+
function unwrap<T>(
82+
result:
83+
| { data: T; failure?: never }
84+
| { data?: never; failure: { message: string } },
85+
label: string,
86+
): T {
87+
assertEquals(
88+
result.failure,
89+
undefined,
90+
`${label}: ${result.failure?.message}`,
91+
)
92+
return result.data as T
93+
}
94+
7595
function assertV3Term(term: unknown, label: string) {
7696
assertExists(term, `${label}: encryptQuery returned null`)
7797
const obj = term as Record<string, unknown>
@@ -119,42 +139,54 @@ Deno.test({
119139

120140
// equality → unique index
121141
assertV3Term(
122-
await client.encryptQuery('alice@example.com', {
123-
table: catalog,
124-
column: catalog.email,
125-
queryType: 'equality',
126-
}),
142+
unwrap(
143+
await client.encryptQuery('alice@example.com', {
144+
table: catalog,
145+
column: catalog.email,
146+
queryType: 'equality',
147+
}),
148+
'equality',
149+
),
127150
'equality',
128151
)
129152

130153
// freeTextSearch → match index
131154
assertV3Term(
132-
await client.encryptQuery('needle phrase', {
133-
table: catalog,
134-
column: catalog.bio,
135-
queryType: 'freeTextSearch',
136-
}),
155+
unwrap(
156+
await client.encryptQuery('needle phrase', {
157+
table: catalog,
158+
column: catalog.bio,
159+
queryType: 'freeTextSearch',
160+
}),
161+
'freeTextSearch',
162+
),
137163
'freeTextSearch',
138164
)
139165

140166
// orderAndRange → ore index (numeric)
141167
assertV3Term(
142-
await client.encryptQuery(42, {
143-
table: catalog,
144-
column: catalog.age,
145-
queryType: 'orderAndRange',
146-
}),
168+
unwrap(
169+
await client.encryptQuery(42, {
170+
table: catalog,
171+
column: catalog.age,
172+
queryType: 'orderAndRange',
173+
}),
174+
'orderAndRange',
175+
),
147176
'orderAndRange',
148177
)
149178

150179
// searchableJson, string value → ste_vec_selector (JSONPath). By
151180
// contract the selector "term" is a BARE selector-hash string — no
152181
// envelope — bound as the text argument of `->` / `->>`.
153-
const selector = await client.encryptQuery('$.theme', {
154-
table: catalog,
155-
column: catalog.prefs,
156-
queryType: 'searchableJson',
157-
})
182+
const selector = unwrap(
183+
await client.encryptQuery('$.theme', {
184+
table: catalog,
185+
column: catalog.prefs,
186+
queryType: 'searchableJson',
187+
}),
188+
'selector',
189+
)
158190
assertExists(selector, 'selector: encryptQuery returned null')
159191
assertEquals(
160192
typeof selector,
@@ -171,30 +203,36 @@ Deno.test({
171203
// strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb
172204
// wire contract)
173205
assertContainmentNeedle(
174-
await client.encryptQuery(
175-
{ theme: 'dark' },
176-
{
177-
table: catalog,
178-
column: catalog.prefs,
179-
queryType: 'searchableJson',
180-
},
206+
unwrap(
207+
await client.encryptQuery(
208+
{ theme: 'dark' },
209+
{
210+
table: catalog,
211+
column: catalog.prefs,
212+
queryType: 'searchableJson',
213+
},
214+
),
215+
'searchableJson/containment',
181216
),
182217
'searchableJson/containment',
183218
)
184219

185220
// Omitted queryType → inference from the column's indexes (TextEq has
186221
// exactly one: unique), mirroring the native client.
187222
assertV3Term(
188-
await client.encryptQuery('bob@example.com', {
189-
table: catalog,
190-
column: catalog.email,
191-
}),
223+
unwrap(
224+
await client.encryptQuery('bob@example.com', {
225+
table: catalog,
226+
column: catalog.email,
227+
}),
228+
'inference',
229+
),
192230
'inference',
193231
)
194232

195233
// Bulk: one round trip across mixed query types, position-stable with
196234
// nulls passing through.
197-
const bulk = await client.encryptQueryBulk([
235+
const bulkResult = await client.encryptQueryBulk([
198236
{
199237
value: 'alice@example.com',
200238
table: catalog,
@@ -225,6 +263,7 @@ Deno.test({
225263
queryType: 'searchableJson',
226264
},
227265
])
266+
const bulk = unwrap(bulkResult, 'bulk')
228267
assertEquals(bulk.length, 5)
229268
assertV3Term(bulk[0], 'bulk/equality')
230269
assertEquals(bulk[1], null, 'bulk: null value must yield null')

e2e/wasm/roundtrip.test.ts

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,19 @@ Deno.test({
102102

103103
const plaintext = `wasm-v3-smoke-${crypto.randomUUID()}@example.com`
104104

105-
const encrypted = await client.encrypt(plaintext, {
105+
// Every fallible method returns `{ data } | { failure }` (#741). Unwrap
106+
// at each boundary — passing an envelope on would fail in ways that look
107+
// like an encryption bug rather than a plumbing one.
108+
const encryptResult = await client.encrypt(plaintext, {
106109
column: users.email,
107110
table: users,
108111
})
112+
assertEquals(
113+
encryptResult.failure,
114+
undefined,
115+
`encrypt() failed: ${encryptResult.failure?.message}`,
116+
)
117+
const encrypted = encryptResult.data
109118

110119
assertEquals(
111120
isEncrypted(encrypted),
@@ -138,17 +147,28 @@ Deno.test({
138147
}
139148
assertEquals(String(wire.v), '3', 'wire envelope is not v3')
140149

141-
const decrypted = await client.decrypt(encrypted)
142-
assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch')
150+
const decryptResult = await client.decrypt(encrypted)
151+
assertEquals(
152+
decryptResult.failure,
153+
undefined,
154+
`decrypt() failed: ${decryptResult.failure?.message}`,
155+
)
156+
assertEquals(decryptResult.data, plaintext, 'round-trip plaintext mismatch')
143157

144158
// 5. (#662) Searchable encryption is reachable on the edge: mint a v3
145159
// QUERY TERM for the column's free-text index. Terms are
146160
// ciphertext-free needles — assert the wire shape, not decryption.
147-
const term = (await client.encryptQuery(plaintext, {
161+
const termResult = await client.encryptQuery(plaintext, {
148162
column: users.email,
149163
table: users,
150164
queryType: 'freeTextSearch',
151-
})) as Record<string, unknown> | null
165+
})
166+
assertEquals(
167+
termResult.failure,
168+
undefined,
169+
`encryptQuery() failed: ${termResult.failure?.message}`,
170+
)
171+
const term = termResult.data as Record<string, unknown> | null
152172
assertExists(term, 'encryptQuery() returned null for live plaintext')
153173
assertEquals(term.v, 3, 'query term is not EQL v3')
154174
assertEquals(
@@ -158,12 +178,48 @@ Deno.test({
158178
)
159179

160180
// Bulk form is position-stable, nulls pass through.
161-
const bulk = await client.encryptQueryBulk([
181+
const bulkResult = await client.encryptQueryBulk([
162182
{ value: plaintext, column: users.email, table: users },
163183
{ value: null, column: users.email, table: users },
164184
])
185+
assertEquals(
186+
bulkResult.failure,
187+
undefined,
188+
`encryptQueryBulk() failed: ${bulkResult.failure?.message}`,
189+
)
190+
const bulk = bulkResult.data
165191
assertEquals(bulk.length, 2)
166192
assertExists(bulk[0])
167193
assertEquals(bulk[1], null)
194+
195+
// 6. (#741) The value-level bulk ops — the whole reason a list read on the
196+
// edge is one ZeroKMS round trip instead of N. Nothing else in the repo
197+
// exercises these against real ZeroKMS.
198+
const second = `wasm-v3-bulk-${crypto.randomUUID()}@example.com`
199+
const bulkEncrypted = await client.bulkEncrypt([
200+
{ plaintext, column: users.email, table: users },
201+
{ plaintext: null, column: users.email, table: users },
202+
{ plaintext: second, column: users.email, table: users },
203+
])
204+
assertEquals(
205+
bulkEncrypted.failure,
206+
undefined,
207+
`bulkEncrypt() failed: ${bulkEncrypted.failure?.message}`,
208+
)
209+
const payloads = bulkEncrypted.data
210+
assertEquals(payloads.length, 3, 'bulkEncrypt is not index-aligned')
211+
assertEquals(payloads[1], null, 'null plaintext did not yield null')
212+
assertExists(payloads[0])
213+
assertExists(payloads[2])
214+
assertEquals(isEncrypted(payloads[0]), true, 'bulkEncrypt[0] not a payload')
215+
216+
const bulkDecrypted = await client.bulkDecrypt(payloads)
217+
assertEquals(
218+
bulkDecrypted.failure,
219+
undefined,
220+
`bulkDecrypt() failed: ${bulkDecrypted.failure?.message}`,
221+
)
222+
// Round-trips at the ORIGINAL indices, with the null hole preserved.
223+
assertEquals(bulkDecrypted.data, [plaintext, null, second])
168224
},
169225
})
Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,25 @@
1-
import type { Result } from '@byteslice/result'
2-
import type { EncryptionError } from '../../src/errors'
1+
import type { WasmResult } from '../../src/wasm-inline'
32

43
/**
5-
* Narrow a `Result` to its success arm in tests.
4+
* Narrow a `WasmResult` to its success arm in tests.
65
*
7-
* `Result<S, F>` is `Success<S> | Failure<F>`, and `Failure` has NO `data`
8-
* property — so `result.data` doesn't type-check until the union is narrowed.
9-
* Every assertion on a successful payload would otherwise need its own
10-
* `if (r.failure) throw` preamble.
6+
* `WasmResult<S>` is a `{ data } | { failure }` union, so `result.data` does
7+
* not type-check until it is narrowed. Every assertion on a successful payload
8+
* would otherwise need its own `if (r.failure) throw` preamble.
119
*
12-
* `F` is pinned to `EncryptionError` rather than left generic: the library
13-
* constrains it to `FailureCase | Error`, and a bare type parameter neither
14-
* satisfies that constraint nor lets TypeScript discriminate the union.
15-
* Every caller here is an encryption operation anyway.
10+
* Typed against `WasmResult` specifically, NOT `@byteslice/result`'s `Result`:
11+
* inference across the two unions collapses `S` to `S | undefined`, so every
12+
* downstream property access reads as possibly-undefined.
1613
*
1714
* Throws (rather than using `expect`) so it stays usable outside an assertion
1815
* context and reports the failure's own message, which is far more useful than
1916
* a bare "expected data to be defined".
2017
*/
21-
export function expectData<S>(result: Result<S, EncryptionError>): S {
18+
export function expectData<S>(result: WasmResult<S>): S {
2219
if (result.failure) {
2320
throw new Error(
2421
`expected { data } but got { failure } (${result.failure.type}): ${result.failure.message}`,
2522
)
2623
}
27-
return result.data
24+
return result.data as S
2825
}

0 commit comments

Comments
 (0)