castrum 0.9.1 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/castrum.linux-x64-gnu.node +0 -0
  3. package/dist/index.d.ts +239 -5
  4. package/dist/index.js +3852 -3375
  5. package/docs/ARCHITECTURE.md +11 -9
  6. package/docs/BENCHMARKS.md +70 -38
  7. package/docs/CASE_STUDY.md +10 -7
  8. package/docs/ENVIRONMENT.md +16 -1
  9. package/docs/FFI_BUN_GUIDE.md +18 -10
  10. package/docs/GETTING_STARTED.md +5 -4
  11. package/docs/HOW_TO_ADD_AN_OP.md +180 -0
  12. package/docs/INGRESS-ROUTER.md +3 -2
  13. package/docs/INGRESS.md +6 -3
  14. package/docs/NATIVE-ROUTE.md +28 -4
  15. package/docs/REPO_MAP.md +60 -30
  16. package/docs/adr/0001-two-wire-formats.md +1 -1
  17. package/docs/ai/LOCAL_DEV.md +81 -0
  18. package/docs/ai/TREE.md +1070 -0
  19. package/package.json +24 -23
  20. package/src/ingress/decode/result-base.ts +10 -2
  21. package/src/ingress/handlers.ts +45 -290
  22. package/src/ingress/headers/security.ts +41 -0
  23. package/src/ingress/index.ts +12 -148
  24. package/src/ingress/native-route.ts +182 -0
  25. package/src/ingress/packing/gather-raw-headers.ts +26 -2
  26. package/src/ingress/packing/route-wire.ts +197 -0
  27. package/src/ingress/packing/select-headers.ts +8 -1
  28. package/src/ingress/path-matcher.ts +115 -0
  29. package/src/ingress/response/baked-response.ts +302 -0
  30. package/src/ingress/router.ts +42 -2
  31. package/src/ingress/routes/common.ts +9 -1
  32. package/src/ingress/routes/json-write.ts +21 -18
  33. package/src/ingress/routes/native.ts +130 -0
  34. package/src/ingress/server-node.ts +1 -1
  35. package/src/ingress/server.ts +38 -99
  36. package/src/ingress/sync.ts +151 -0
  37. package/src/ingress/types.ts +9 -0
  38. package/src/native/ffi/build/codecs.ts +736 -0
  39. package/src/native/ffi/build/compress.ts +248 -0
  40. package/src/native/ffi/build/instances.ts +598 -0
  41. package/src/native/ffi/build/parse.ts +539 -0
  42. package/src/native/ffi/build/util.ts +140 -0
  43. package/src/native/ffi/build.ts +94 -0
  44. package/src/native/ffi/selftest.ts +11 -718
  45. package/src/native/ffi/types.ts +25 -0
  46. package/src/native/ffi.ts +36 -1320
  47. package/src/native/types/instances.ts +5 -0
  48. package/src/rust-ffi/packed.ts +38 -0
  49. package/src/rust-ffi/scalar/factories.ts +4 -0
@@ -0,0 +1,736 @@
1
+ // src/native/ffi/build/codecs.ts — hashing / codec / crypto / auth BunFFI methods.
2
+ //
3
+ // Checksums, JSON/UTF-8 validity, hex + percent codecs, base64, HMAC, cookie
4
+ // signing, CSRF, random tokens, password hashing, PBKDF2, AEAD, and the
5
+ // cstring-based ws accept key + ETag. Receives the raw dlopen'd symbols and
6
+ // the per-bind context (argument adapters + pooled scratch) from `build()`.
7
+
8
+ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
9
+ import { SELFTEST_HEX, SELFTEST_JSON } from '../constants'
10
+ import type { BunFFI, Raw3, Raw4, Raw5, Raw6, Raw8, Raw9, Raw10, RawCStr } from '../types'
11
+ import type { BuildCtx } from './util'
12
+ import { argon2PhcLength, cstr, flag, growExact, writeOrThrow } from './util'
13
+
14
+ /**
15
+ * Build the codec/crypto/auth methods of the BunFFI surface. `ctx` is
16
+ * destructured so the method bodies read exactly as the original `build()`.
17
+ */
18
+ export function buildCodecs(
19
+ sym: Record<string, (...a: unknown[]) => unknown>,
20
+ ctx: BuildCtx,
21
+ ): Partial<BunFFI> {
22
+ const { lenOrView, oneArg, scratchFor, jsonSumOut, jsonSumView } = ctx
23
+
24
+ const crc32 = sym.castrum_crc32 as (...a: unknown[]) => number | bigint
25
+ const fnv = sym.castrum_fnv1a64 as (...a: unknown[]) => number | bigint
26
+ const xxh = sym.castrum_xxh3 as (...a: unknown[]) => number | bigint
27
+ const jsonValid = sym.castrum_json_valid as (...a: unknown[]) => number | bigint
28
+ const utf8Valid = sym.castrum_utf8_valid as (...a: unknown[]) => number | bigint
29
+ const hexEncode = sym.castrum_hex_encode as Raw4
30
+ const hexDecode = sym.castrum_hex_decode as Raw4
31
+ const urlEncode = sym.castrum_url_encode as Raw4
32
+ const urlDecode = sym.castrum_url_decode as Raw4
33
+ const validateEmail = sym.castrum_validate_email as (...a: unknown[]) => number | bigint
34
+ const validateUuid = sym.castrum_validate_uuid as (...a: unknown[]) => number | bigint
35
+ const validateIpv4 = sym.castrum_validate_ipv4 as (...a: unknown[]) => number | bigint
36
+ const validateIpv6 = sym.castrum_validate_ipv6 as (...a: unknown[]) => number | bigint
37
+ const jsonSumRaw = sym.castrum_json_sum_ids as Raw4
38
+ const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
39
+ const csrfVerify = sym.castrum_csrf_verify as Raw4
40
+ const passwordVerify = sym.castrum_password_verify as Raw4
41
+ const passwordVerifyBcrypt = sym.castrum_password_verify_bcrypt as Raw3
42
+ const wsAcceptKey = sym.castrum_ws_accept_key as RawCStr
43
+ const wsAcceptKeyInto = sym.castrum_ws_accept_key_into as Raw4
44
+ const etagCStr = sym.castrum_etag as RawCStr
45
+ const etagIntoRaw = sym.castrum_etag_into as Raw5
46
+ const randomToken = sym.castrum_random_token as RawCStr
47
+ const randomTokenInto = sym.castrum_random_token_into as Raw3
48
+ const base64Encode = sym.castrum_base64_encode as Raw6
49
+ const base64Decode = sym.castrum_base64_decode as Raw6
50
+ const hmacSha256 = sym.castrum_hmac_sha256 as Raw6
51
+ const signCookie = sym.castrum_sign_cookie as RawCStr
52
+ const signCookieInto = sym.castrum_sign_cookie_into as Raw6
53
+ const verifyCookie = sym.castrum_verify_cookie as RawCStr
54
+ const verifyCookieInto = sym.castrum_verify_cookie_into as Raw6
55
+ const csrfToken = sym.castrum_csrf_token as RawCStr
56
+ const csrfTokenInto = sym.castrum_csrf_token_into as Raw4
57
+ const passwordHash = sym.castrum_password_hash as Raw10
58
+ const passwordHashBcrypt = sym.castrum_password_hash_bcrypt as Raw5
59
+ const pbkdf2 = sym.castrum_pbkdf2_sha256 as Raw8
60
+ const aeadEncrypt = sym.castrum_aead_encrypt as Raw9
61
+ const aeadDecrypt = sym.castrum_aead_decrypt as Raw9
62
+
63
+ // ── Encoder / decoder `Into` helpers ──────────────────────────────
64
+ const hexEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
65
+ // Mirror napi error semantics: a too-small buffer throws, not returns 0.
66
+ // Hex encode always writes exactly `input.length * 2` bytes on success.
67
+ if (output.length < input.length * 2) {
68
+ throw new Error('hex encode: output buffer too small')
69
+ }
70
+ return Number(hexEncode(input, lenOrView(input), output, lenOrView(output)))
71
+ }
72
+
73
+ const urlEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
74
+ const w = Number(urlEncode(input, lenOrView(input), output, lenOrView(output)))
75
+ // Every input byte encodes to >= 1 output byte, so a 0 write on non-empty
76
+ // input means the buffer was too small (napi throws there too). Empty
77
+ // input legitimately writes 0.
78
+ if (w === 0 && input.length !== 0) {
79
+ throw new Error('url encode: output buffer too small')
80
+ }
81
+ return w
82
+ }
83
+
84
+ const hexDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
85
+ const w = Number(hexDecode(input, lenOrView(input), output, lenOrView(output)))
86
+ return writeOrThrow(w, input.length, 'hex decode')
87
+ }
88
+
89
+ const urlDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
90
+ const w = Number(urlDecode(input, lenOrView(input), output, lenOrView(output)))
91
+ return writeOrThrow(w, input.length, 'url decode')
92
+ }
93
+
94
+ const base64DecodeInto = (
95
+ input: Uint8Array,
96
+ output: Uint8Array,
97
+ urlSafe?: boolean,
98
+ padding?: boolean,
99
+ ): number => {
100
+ // napi defaults: urlSafe=false, padding=true (rust/crypto/base64.rs).
101
+ const w = Number(
102
+ base64Decode(
103
+ input,
104
+ lenOrView(input),
105
+ output,
106
+ lenOrView(output),
107
+ flag(urlSafe),
108
+ flag(padding ?? true),
109
+ ),
110
+ )
111
+ return writeOrThrow(w, input.length, 'base64 decode')
112
+ }
113
+
114
+ const base64EncodeInto = (
115
+ input: Uint8Array,
116
+ output: Uint8Array,
117
+ urlSafe?: boolean,
118
+ padding?: boolean,
119
+ ): number => {
120
+ // napi defaults: urlSafe=false, padding=true.
121
+ const w = Number(
122
+ base64Encode(
123
+ input,
124
+ lenOrView(input),
125
+ output,
126
+ lenOrView(output),
127
+ flag(urlSafe),
128
+ flag(padding ?? true),
129
+ ),
130
+ )
131
+ // Empty input legitimately writes 0 bytes — only a 0 write on NON-empty
132
+ // input is a real error (same convention as the decode/etag paths).
133
+ return writeOrThrow(w, input.length, 'base64 encode')
134
+ }
135
+
136
+ const etagInto = (data: Uint8Array, output: Uint8Array, weak?: boolean): number => {
137
+ // Native pooled `_into`: writes 10/12 bytes directly into the caller
138
+ // buffer (no cstring round-trip). Needed-size convention: a write larger
139
+ // than `output.length` reports the exact required size → throw.
140
+ const w = Number(etagIntoRaw(data, lenOrView(data), flag(weak), output, lenOrView(output)))
141
+ if (w === 0) {
142
+ throw new Error('etag: invalid input')
143
+ }
144
+ if (w > output.length) {
145
+ throw new Error('etag: output buffer too small')
146
+ }
147
+ return w
148
+ }
149
+
150
+ return {
151
+ crc32: (input) => Number(oneArg(crc32, input)) >>> 0,
152
+ fnv1a64: (input) => BigInt(oneArg(fnv, input)),
153
+ xxh3: (input) => BigInt(oneArg(xxh, input)),
154
+ jsonValid: (input) => Number(oneArg(jsonValid, input)) === 1,
155
+ utf8Valid: (input) => Number(oneArg(utf8Valid, input)) === 1,
156
+ hexEncode(input) {
157
+ // Pooled scratch — decoded synchronously to an immutable string (safe;
158
+ // removes the per-call `new Uint8Array(len*2)`). ALWAYS decode the
159
+ // written subarray: the shared scratch may be larger than `w` (grown by
160
+ // an earlier op), so decoding the whole buffer would read stale bytes.
161
+ const out = scratchFor(input.length * 2)
162
+ const w = hexEncodeInto(input, out)
163
+ return decodeUtf8(out.subarray(0, w))
164
+ },
165
+ hexEncodeInto,
166
+ urlEncode(input) {
167
+ // RFC 3986 worst case is 3 bytes per input byte (`%XX`). Pooled scratch
168
+ // (decoded synchronously — safe).
169
+ const out = scratchFor(input.length * 3)
170
+ const w = urlEncodeInto(input, out)
171
+ return decodeUtf8(out.subarray(0, w))
172
+ },
173
+ urlEncodeInto,
174
+
175
+ validateEmail: (input) => Number(validateEmail(input)) === 1,
176
+ validateUuid: (input) => Number(validateUuid(input)) === 1,
177
+ validateIpv4: (input) => Number(validateIpv4(input)) === 1,
178
+ validateIpv6: (input) => Number(validateIpv6(input)) === 1,
179
+ jsonSumIds: (input) => {
180
+ // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
181
+ // may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
182
+ const w = Number(jsonSumRaw(input, lenOrView(input), jsonSumOut, lenOrView(jsonSumOut)))
183
+ if (w === 0) {
184
+ throw new Error('json sum ids: output buffer too small')
185
+ }
186
+ if (jsonSumOut[0] === 0) {
187
+ // Mirrors the napi error phrasing (serde: "expected an array of objects
188
+ // with numeric ids") so both transports throw the same message.
189
+ throw new Error('json sum ids: expected an array of objects with numeric ids')
190
+ }
191
+ return jsonSumView.getBigInt64(1, true)
192
+ },
193
+ hmacSha256Verify: (key, data, signature) =>
194
+ Number(
195
+ hmacVerify(key, lenOrView(key), data, lenOrView(data), signature, lenOrView(signature)),
196
+ ) === 1,
197
+ csrfVerify: (token, secret) =>
198
+ Number(csrfVerify(token, lenOrView(token), secret, lenOrView(secret))) === 1,
199
+ passwordVerify: (password, phc) =>
200
+ Number(passwordVerify(password, lenOrView(password), phc, lenOrView(phc))) === 1,
201
+ passwordVerifyBcrypt: (password, phc) =>
202
+ Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
203
+
204
+ hexDecode(input) {
205
+ const out = new Uint8Array(Math.floor(input.length / 2))
206
+ const w = hexDecodeInto(input, out)
207
+ return out.subarray(0, w)
208
+ },
209
+ hexDecodeInto,
210
+ urlDecode(input) {
211
+ const out = new Uint8Array(input.length)
212
+ const w = urlDecodeInto(input, out)
213
+ return out.subarray(0, w)
214
+ },
215
+ urlDecodeInto,
216
+ base64Decode(input, urlSafe, padding) {
217
+ const out = new Uint8Array(Math.ceil((input.length * 3) / 4))
218
+ const w = base64DecodeInto(input, out, urlSafe, padding)
219
+ return out.subarray(0, w)
220
+ },
221
+ base64DecodeInto,
222
+
223
+ wsAcceptKey(key) {
224
+ return cstr(wsAcceptKey(key), 'ws accept key: bad key')
225
+ },
226
+ wsAcceptKeyInto(key, output) {
227
+ // Native pooled `_into`: writes the 28-byte accept key directly into the
228
+ // caller buffer (no cstring round-trip). Needed-size convention.
229
+ const w = Number(wsAcceptKeyInto(key, lenOrView(key), output, lenOrView(output)))
230
+ if (w === 0) {
231
+ throw new Error('ws accept key: bad key')
232
+ }
233
+ if (w > output.length) {
234
+ throw new Error('ws accept key: output buffer too small')
235
+ }
236
+ return w
237
+ },
238
+ etag(data, weak) {
239
+ // cstring return (10 strong / 12 weak chars) — zero encode.
240
+ return cstr(etagCStr(data, lenOrView(data), flag(weak)), 'etag: invalid input')
241
+ },
242
+ etagInto,
243
+ randomToken(byteLen) {
244
+ // cstring return of `byteLen*2` hex chars; byteLen 0 → empty string → empty
245
+ // Uint8Array (napi returns empty too). null = random source failed / >16MiB.
246
+ return cstr(
247
+ randomToken(byteLen),
248
+ 'random token: output buffer too small or random source failed',
249
+ )
250
+ },
251
+ randomTokenInto(byteLen, output) {
252
+ // Pooled sibling: native writes `byteLen*2` hex chars directly into the
253
+ // caller buffer (no cstring round-trip). Needed-size convention: a write
254
+ // larger than `output.length` reports the exact required size → throw
255
+ // (the caller owns the buffer); 0 = real error (cap / RNG).
256
+ const w = Number(randomTokenInto(byteLen, output, lenOrView(output)))
257
+ if (w === 0) {
258
+ throw new Error('random token: random source failed or byteLen exceeds 16 MiB')
259
+ }
260
+ if (w > output.length) {
261
+ throw new Error('random token: output buffer too small')
262
+ }
263
+ return w
264
+ },
265
+ base64Encode(input, urlSafe, padding) {
266
+ // Pooled scratch — decoded synchronously to an immutable string (safe).
267
+ const out = scratchFor(Math.ceil(input.length / 3) * 4)
268
+ const w = base64EncodeInto(input, out, urlSafe, padding)
269
+ return decodeUtf8(out.subarray(0, w))
270
+ },
271
+ base64EncodeInto,
272
+ hmacSha256(key, data) {
273
+ // Pooled 64-byte scratch — decoded synchronously (safe).
274
+ const out = scratchFor(64)
275
+ const w = Number(hmacSha256(key, lenOrView(key), data, lenOrView(data), out, lenOrView(out)))
276
+ if (w === 0) {
277
+ throw new Error('hmac sha256: output buffer too small')
278
+ }
279
+ return decodeUtf8(out.subarray(0, w))
280
+ },
281
+ hmacSha256Into(key, data, output) {
282
+ if (output.length < 64) {
283
+ throw new Error('hmac sha256: output buffer too small')
284
+ }
285
+ const w = Number(
286
+ hmacSha256(key, lenOrView(key), data, lenOrView(data), output, lenOrView(output)),
287
+ )
288
+ if (w === 0) {
289
+ throw new Error('hmac sha256: output buffer too small')
290
+ }
291
+ return w
292
+ },
293
+ signCookie(value, secret) {
294
+ // `value.<64-hex>` returned as a cstring (value.length + 1 + 64 chars).
295
+ return cstr(
296
+ signCookie(value, lenOrView(value), secret, lenOrView(secret)),
297
+ 'sign cookie: invalid input',
298
+ )
299
+ },
300
+ signCookieInto(value, secret, output) {
301
+ // Native pooled `_into`: writes `value.<64-hex>` directly into the caller
302
+ // buffer (no cstring round-trip — this is why pooled sign_cookie was
303
+ // previously a REGRESSION vs allocating). Needed-size convention.
304
+ const w = Number(
305
+ signCookieInto(
306
+ value,
307
+ lenOrView(value),
308
+ secret,
309
+ lenOrView(secret),
310
+ output,
311
+ lenOrView(output),
312
+ ),
313
+ )
314
+ if (w === 0) {
315
+ throw new Error('sign cookie: invalid input')
316
+ }
317
+ if (w > output.length) {
318
+ throw new Error('sign cookie: output buffer too small')
319
+ }
320
+ return w
321
+ },
322
+ verifyCookie(signed, secret) {
323
+ // cstring return; `null` = invalid signature / malformed → null (napi parity).
324
+ // CAVEAT (refactor): the engine clones a UTF-8 string, so a cookie VALUE
325
+ // containing non-UTF-8 bytes or NUL cannot round-trip byte-faithfully on
326
+ // the FFI path (napi still does). Signed values are ASCII in practice.
327
+ const s = verifyCookie(signed, lenOrView(signed), secret, lenOrView(secret))
328
+ return s === null ? null : s
329
+ },
330
+ verifyCookieInto(signed, secret, output) {
331
+ // Native pooled `_into`: writes the verified value directly into the
332
+ // caller buffer. 0 = invalid signature / malformed → null (napi parity,
333
+ // like the allocating `verifyCookie`); a write larger than `output.length`
334
+ // reports the exact required size → throw.
335
+ const w = Number(
336
+ verifyCookieInto(
337
+ signed,
338
+ lenOrView(signed),
339
+ secret,
340
+ lenOrView(secret),
341
+ output,
342
+ lenOrView(output),
343
+ ),
344
+ )
345
+ if (w === 0) {
346
+ return null
347
+ }
348
+ if (w > output.length) {
349
+ throw new Error('verify cookie: output buffer too small')
350
+ }
351
+ return w
352
+ },
353
+ csrfToken(secret) {
354
+ // 129-char `hex.hex` cstring return.
355
+ return cstr(
356
+ csrfToken(secret, lenOrView(secret)),
357
+ 'csrf token: output buffer too small or random source failed',
358
+ )
359
+ },
360
+ csrfTokenInto(secret, output) {
361
+ // Native pooled `_into`: writes the 129-char `hex.hex` token directly
362
+ // into the caller buffer (no cstring round-trip). Needed-size convention.
363
+ const w = Number(csrfTokenInto(secret, lenOrView(secret), output, lenOrView(output)))
364
+ if (w === 0) {
365
+ throw new Error('csrf token: random source failed')
366
+ }
367
+ if (w > output.length) {
368
+ throw new Error('csrf token: output buffer too small')
369
+ }
370
+ return w
371
+ },
372
+ passwordHash(password, salt, mCost, tCost, pCost, outLen) {
373
+ // Pre-size with the EXACT PHC string length (computable from the params),
374
+ // so the hash runs once — a grow-retry would re-run the whole argon2
375
+ // hash on a miss. growExact remains the safety net.
376
+ return decodeUtf8(
377
+ growExact(
378
+ (out) =>
379
+ Number(
380
+ passwordHash(
381
+ password,
382
+ lenOrView(password),
383
+ salt,
384
+ lenOrView(salt),
385
+ mCost,
386
+ tCost,
387
+ pCost,
388
+ outLen,
389
+ out,
390
+ lenOrView(out),
391
+ ),
392
+ ),
393
+ argon2PhcLength(mCost, tCost, pCost, salt.length, outLen),
394
+ 2 * 1024 * 1024,
395
+ 'password hash: output buffer too small',
396
+ ),
397
+ )
398
+ },
399
+ passwordHashBcrypt(password, cost) {
400
+ // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
401
+ const out = new Uint8Array(64)
402
+ const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
403
+ if (w === 0) {
404
+ throw new Error('password hash bcrypt: output buffer too small')
405
+ }
406
+ return decodeUtf8(out.subarray(0, w))
407
+ },
408
+ pbkdf2Sha256(password, salt, rounds, dkLen) {
409
+ // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
410
+ // own buffer — so pre-clamp here so dkLen 0 still yields a 1-byte result.
411
+ const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
412
+ const out = new Uint8Array(dk)
413
+ const w = Number(
414
+ pbkdf2(
415
+ password,
416
+ lenOrView(password),
417
+ salt,
418
+ lenOrView(salt),
419
+ rounds,
420
+ dkLen,
421
+ out,
422
+ lenOrView(out),
423
+ ),
424
+ )
425
+ if (w === 0) {
426
+ throw new Error('pbkdf2: output buffer too small')
427
+ }
428
+ return out.subarray(0, w)
429
+ },
430
+ aeadEncrypt(key, nonce, plaintext, algorithm = 0) {
431
+ // ciphertext + 16-byte auth tag.
432
+ const out = new Uint8Array(plaintext.length + 16)
433
+ const w = Number(
434
+ aeadEncrypt(
435
+ key,
436
+ lenOrView(key),
437
+ nonce,
438
+ lenOrView(nonce),
439
+ plaintext,
440
+ lenOrView(plaintext),
441
+ algorithm,
442
+ out,
443
+ lenOrView(out),
444
+ ),
445
+ )
446
+ if (w === 0) {
447
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
448
+ }
449
+ return out.subarray(0, w)
450
+ },
451
+ aeadEncryptInto(key, nonce, plaintext, output, algorithm = 0) {
452
+ const need = plaintext.length + 16
453
+ if (output.length < need) {
454
+ throw new Error('aead encrypt: output buffer too small')
455
+ }
456
+ const w = Number(
457
+ aeadEncrypt(
458
+ key,
459
+ lenOrView(key),
460
+ nonce,
461
+ lenOrView(nonce),
462
+ plaintext,
463
+ lenOrView(plaintext),
464
+ algorithm,
465
+ output,
466
+ lenOrView(output),
467
+ ),
468
+ )
469
+ if (w === 0) {
470
+ throw new Error('aead encrypt: output buffer too small or bad parameters')
471
+ }
472
+ return w
473
+ },
474
+ aeadDecrypt(key, nonce, ciphertext, algorithm = 0) {
475
+ const out = new Uint8Array(ciphertext.length)
476
+ const w = Number(
477
+ aeadDecrypt(
478
+ key,
479
+ lenOrView(key),
480
+ nonce,
481
+ lenOrView(nonce),
482
+ ciphertext,
483
+ lenOrView(ciphertext),
484
+ algorithm,
485
+ out,
486
+ lenOrView(out),
487
+ ),
488
+ )
489
+ return w === 0 ? null : out.subarray(0, w)
490
+ },
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Bind-time self-test for the codec/crypto/auth surface (the methods built in
496
+ * `buildCodecs`). Exercises every bound function against known-good vectors;
497
+ * `false` disables the ffi layer and forces the napi fallback.
498
+ */
499
+ export function selfTestCodecs(b: BunFFI): boolean {
500
+ const enc = { encode: encodeUtf8 }
501
+ const dec = { decode: decodeUtf8 }
502
+
503
+ // Checksums + validity probes.
504
+ if (b.crc32(enc.encode('123456789')) !== 0xcbf4_3926) return false
505
+ if (b.fnv1a64(enc.encode('foobar')) !== 0x8594_4171_f739_67e8n) return false
506
+ // XXH3-64 of empty input = 0x2d06800538d394c2 (standard reference vector).
507
+ if (b.xxh3(new Uint8Array(0)) !== 0x2d06800538d394c2n) return false
508
+ if (b.jsonValid(SELFTEST_JSON) !== true || b.jsonValid(enc.encode('{not json')) !== false) {
509
+ return false
510
+ }
511
+ // UTF-8 validity probe (native replacement for a fatal TextDecoder on the
512
+ // Bun path — used by the urlDecode wrapper).
513
+ if (!b.utf8Valid(enc.encode('héllo')) || b.utf8Valid(new Uint8Array([0xff, 0xfe]))) {
514
+ return false
515
+ }
516
+ const hexOut = new Uint8Array(SELFTEST_HEX.length * 2)
517
+ if (b.hexEncodeInto(SELFTEST_HEX, hexOut) !== 10 || dec.decode(hexOut) !== '68656c6c6f') {
518
+ return false
519
+ }
520
+ const urlInput = enc.encode('a b/c')
521
+ const urlOut = new Uint8Array(9)
522
+ if (b.urlEncodeInto(urlInput, urlOut) !== 9 || dec.decode(urlOut) !== 'a%20b%2Fc') {
523
+ return false
524
+ }
525
+
526
+ // Validators take `cstring` ARGs (the engine transcodes the JS string).
527
+ if (
528
+ !b.validateEmail('a@b.com') ||
529
+ !b.validateUuid('550e8400-e29b-41d4-a716-446655440000') ||
530
+ !b.validateIpv4('192.168.0.1') ||
531
+ !b.validateIpv6('2001:db8::1') ||
532
+ b.validateEmail('not-an-email') ||
533
+ b.validateUuid('not-a-uuid')
534
+ ) {
535
+ return false
536
+ }
537
+ // Packed `[u8 ok][i64 sum LE]` ABI: legit zero-sum is ok, invalid input throws.
538
+ if (b.jsonSumIds(enc.encode(`[{"id":1},{"id":2}]`)) !== 3n) return false
539
+ if (b.jsonSumIds(enc.encode(`[{"id":0},{"id":0}]`)) !== 0n) return false
540
+ let sumInvalidThrew = false
541
+ try {
542
+ b.jsonSumIds(enc.encode('nope'))
543
+ } catch {
544
+ sumInvalidThrew = true
545
+ }
546
+ if (!sumInvalidThrew) return false
547
+
548
+ // HMAC RFC 4231 test case 1 (0x0b × 20 key, "Hi There" data).
549
+ const hmacKey = new Uint8Array(20).fill(0x0b)
550
+ const hmacData = enc.encode('Hi There')
551
+ const hmacSig = enc.encode('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')
552
+ if (
553
+ b.hmacSha256(hmacKey, hmacData) !==
554
+ 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
555
+ ) {
556
+ return false
557
+ }
558
+ if (!b.hmacSha256Verify(hmacKey, hmacData, hmacSig)) {
559
+ return false
560
+ }
561
+
562
+ // Decoders round-trip.
563
+ const decoded = b.hexDecode(enc.encode('68656c6c6f'))
564
+ if (
565
+ dec.decode(decoded) !== 'hello' ||
566
+ dec.decode(b.urlDecode(enc.encode('a%20b%2Fc'))) !== 'a b/c'
567
+ ) {
568
+ return false
569
+ }
570
+
571
+ // WebSocket accept key (RFC 6455 sample) — `key` is a `cstring` ARG.
572
+ if (b.wsAcceptKey('dGhlIHNhbXBsZSBub25jZQ==') !== 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=') {
573
+ return false
574
+ }
575
+ // ETag: strong = 10 bytes, weak = 12 bytes.
576
+ if (b.etag(SELFTEST_HEX).length !== 10 || b.etag(SELFTEST_HEX, true).length !== 12) {
577
+ return false
578
+ }
579
+
580
+ // base64.
581
+ if (b.base64Encode(SELFTEST_HEX) !== 'aGVsbG8=') {
582
+ return false
583
+ }
584
+ if (dec.decode(b.base64Decode(enc.encode('aGVsbG8='))) !== 'hello') {
585
+ return false
586
+ }
587
+
588
+ // Signed cookie round-trip.
589
+ const secret = enc.encode('s3cr3t-secret')
590
+ const signed = b.signCookie(SELFTEST_HEX, secret)
591
+ const verified = b.verifyCookie(enc.encode(signed), secret)
592
+ if (verified === null || verified !== 'hello') {
593
+ return false
594
+ }
595
+ if (b.verifyCookie(enc.encode('tampered.0000'), secret) !== null) {
596
+ return false
597
+ }
598
+
599
+ // CSRF token round-trip (issued token verifies against the same secret).
600
+ const csrfTokenStr = b.csrfToken(secret)
601
+ if (csrfTokenStr.length !== 129 || !b.csrfVerify(enc.encode(csrfTokenStr), secret)) {
602
+ return false
603
+ }
604
+
605
+ // Argon2id round-trip at minimum cost (fast) — full defaults would take ~50ms.
606
+ const pw = enc.encode('correct horse battery staple')
607
+ const salt = enc.encode('salty-salt-16b')
608
+ const phc = b.passwordHash(pw, salt, 8, 1, 1, 16)
609
+ if (phc.length === 0 || !b.passwordVerify(pw, enc.encode(phc))) {
610
+ return false
611
+ }
612
+ // bcrypt round-trip at minimum cost (fast) — `phc` is a `cstring` ARG.
613
+ const bcryptPhc = b.passwordHashBcrypt(pw, 4)
614
+ if (bcryptPhc.length === 0 || !b.passwordVerifyBcrypt(pw, bcryptPhc)) {
615
+ return false
616
+ }
617
+ // PBKDF2-HMAC-SHA256: password="password", salt="salt", c=1, dkLen=32.
618
+ const dk = b.pbkdf2Sha256(enc.encode('password'), enc.encode('salt'), 1, 32)
619
+ if (b.hexEncode(dk) !== '120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b') {
620
+ return false
621
+ }
622
+
623
+ // AEAD AES-256-GCM round-trip (key 32B, nonce 12B).
624
+ const aeadKey = new Uint8Array(32).fill(0x42)
625
+ const nonce = new Uint8Array(12).fill(0x07)
626
+ const ct = b.aeadEncrypt(aeadKey, nonce, SELFTEST_HEX, 0)
627
+ const pt = b.aeadDecrypt(aeadKey, nonce, ct, 0)
628
+ if (pt === null || dec.decode(pt) !== 'hello') {
629
+ return false
630
+ }
631
+
632
+ // Random token pooled Into: writes byteLen*2 hex chars into the buffer.
633
+ const rtOut = new Uint8Array(32)
634
+ const rtW = b.randomTokenInto(16, rtOut)
635
+ if (rtW !== 32 || !/^[0-9a-f]{32}$/.test(dec.decode(rtOut))) {
636
+ return false
637
+ }
638
+ // Too-small buffer → throw (needed-size convention).
639
+ try {
640
+ b.randomTokenInto(16, new Uint8Array(8))
641
+ return false
642
+ } catch {
643
+ // expected
644
+ }
645
+
646
+ // Fixed-size writers pooled Into: ws_accept_key + etag + csrf_token match the
647
+ // cstring path byte-for-byte.
648
+ const waOut = new Uint8Array(28)
649
+ const waW = b.wsAcceptKeyInto(enc.encode('dGhlIHNhbXBsZSBub25jZQ=='), waOut)
650
+ const waExpected = b.wsAcceptKey('dGhlIHNhbXBsZSBub25jZQ==')
651
+ if (waW !== 28 || dec.decode(waOut) !== waExpected) {
652
+ return false
653
+ }
654
+ const etagData = enc.encode('hello')
655
+ const etagOut = new Uint8Array(16)
656
+ const etagW = b.etagInto(etagData, etagOut)
657
+ if (etagW !== 10 || dec.decode(etagOut.subarray(0, etagW)) !== b.etag(etagData)) {
658
+ return false
659
+ }
660
+ const csrfOut = new Uint8Array(129)
661
+ const csrfW = b.csrfTokenInto(enc.encode('csrf-secret'), csrfOut)
662
+ if (csrfW !== 129 || csrfOut[64] !== 46 /* '.' */) {
663
+ return false
664
+ }
665
+
666
+ // sign/verify cookie pooled Into: round-trip value bytes.
667
+ const ckVal = enc.encode('session-value')
668
+ const ckSec = enc.encode('s3cr3t-secret')
669
+ const ckOut = new Uint8Array(256)
670
+ const ckW = b.signCookieInto(ckVal, ckSec, ckOut)
671
+ if (ckW !== ckVal.length + 65) {
672
+ return false
673
+ }
674
+ const ckVerifyOut = new Uint8Array(256)
675
+ const ckV = b.verifyCookieInto(ckOut.subarray(0, ckW), ckSec, ckVerifyOut)
676
+ if (ckV !== ckVal.length || dec.decode(ckVerifyOut.subarray(0, ckV)) !== 'session-value') {
677
+ return false
678
+ }
679
+
680
+ // urlEncode string form.
681
+ if (b.urlEncode(enc.encode('a b/c')) !== 'a%20b%2Fc') {
682
+ return false
683
+ }
684
+ // hexDecodeInto.
685
+ const hdInto = new Uint8Array(8)
686
+ if (
687
+ b.hexDecodeInto(enc.encode('68656c6c6f'), hdInto) !== 5 ||
688
+ dec.decode(hdInto.subarray(0, 5)) !== 'hello'
689
+ ) {
690
+ return false
691
+ }
692
+ // urlDecodeInto.
693
+ const udInto = new Uint8Array(16)
694
+ if (
695
+ b.urlDecodeInto(enc.encode('a%20b%2Fc'), udInto) !== 5 ||
696
+ dec.decode(udInto.subarray(0, 5)) !== 'a b/c'
697
+ ) {
698
+ return false
699
+ }
700
+ // base64EncodeInto.
701
+ const b64Into = new Uint8Array(16)
702
+ if (
703
+ b.base64EncodeInto(SELFTEST_HEX, b64Into) !== 8 ||
704
+ dec.decode(b64Into.subarray(0, 8)) !== 'aGVsbG8='
705
+ ) {
706
+ return false
707
+ }
708
+ // base64DecodeInto.
709
+ const bdInto = new Uint8Array(8)
710
+ if (
711
+ b.base64DecodeInto(enc.encode('aGVsbG8='), bdInto) !== 5 ||
712
+ dec.decode(bdInto.subarray(0, 5)) !== 'hello'
713
+ ) {
714
+ return false
715
+ }
716
+ // randomToken string form.
717
+ if (!/^[0-9a-f]{32}$/.test(b.randomToken(16))) {
718
+ return false
719
+ }
720
+ // hmacSha256Into (RFC 4231 test case 1 hex vector).
721
+ const hmacInto = new Uint8Array(64)
722
+ if (
723
+ b.hmacSha256Into(hmacKey, hmacData, hmacInto) !== 64 ||
724
+ dec.decode(hmacInto) !== 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7'
725
+ ) {
726
+ return false
727
+ }
728
+ // aeadEncryptInto → decrypt round-trip.
729
+ const ctInto = new Uint8Array(64)
730
+ const ctW = b.aeadEncryptInto(aeadKey, nonce, SELFTEST_HEX, ctInto, 0)
731
+ if (ctW === 0 || b.aeadDecrypt(aeadKey, nonce, ctInto.subarray(0, ctW), 0) === null) {
732
+ return false
733
+ }
734
+
735
+ return true
736
+ }