castrum 0.9.3 → 0.9.6

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 (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -8,22 +8,22 @@
8
8
  // - sseResponse → server-sent-events response framed on the fast path
9
9
 
10
10
  export {
11
+ runMany,
12
+ runOne,
13
+ validateCount,
14
+ validateMany,
15
+ } from './batch'
16
+ export {
17
+ type CreatePipelineOptions,
11
18
  createPipeline,
12
19
  type IngressPipeline,
13
- type PipelineResult,
14
20
  type PipelineContext,
21
+ type PipelineResult,
15
22
  type PreprocessOutcome,
16
- type CreatePipelineOptions,
17
23
  } from './pipeline'
24
+ export { type SseEvent, sseResponse } from './streaming'
18
25
  export {
19
26
  createWebSocketUpgrade,
20
27
  type WebSocketUpgradeOptions,
21
28
  type WebSocketUpgradeResult,
22
29
  } from './websocket'
23
- export { sseResponse, type SseEvent } from './streaming'
24
- export {
25
- validateMany,
26
- validateCount,
27
- runMany,
28
- runOne,
29
- } from './batch'
@@ -7,18 +7,18 @@
7
7
  // context to the app's own handler. `handleRequest` is a fetch-compatible
8
8
  // convenience that serves the terminal response or a rendered OK response.
9
9
 
10
+ import { readBodyWithLimit } from '../ingress/body'
11
+ import type { BakedIngressResult } from '../ingress/decode/baked-result'
10
12
  import {
11
- createIngressHandler,
12
13
  type BakedIngressRuntime,
14
+ createIngressHandler,
13
15
  type OptimizedIngressHandler,
14
16
  } from '../ingress/handlers'
15
- import type { BakedContext } from '../ingress/types'
16
17
  import type { IngressHandlerOptions } from '../ingress/options'
17
- import { readBodyWithLimit } from '../ingress/body'
18
- import { DEFAULT_MAX_BODY_BYTES, DEFAULT_BODY_TIMEOUT_MS } from '../ingress/shared'
19
- import { generateRequestId } from '../shared/request-id'
18
+ import { DEFAULT_BODY_TIMEOUT_MS, DEFAULT_MAX_BODY_BYTES } from '../ingress/shared'
19
+ import type { BakedContext } from '../ingress/types'
20
20
  import { decoder } from '../shared/bytes'
21
- import type { BakedIngressResult } from '../ingress/decode/baked-result'
21
+ import { generateRequestId } from '../shared/request-id'
22
22
 
23
23
  /** Readonly snapshot of the ingress decision, safe after `run()` returns. */
24
24
  export interface PipelineResult {
@@ -1,8 +1,8 @@
1
1
  // src/integration/websocket.ts — WebSocket upgrade handshake helper.
2
2
 
3
- import { isBun } from '../shared/runtime'
4
3
  import { rust } from '../rust-ffi'
5
- /** Options for `createWebSocketUpgrade` (supported subprotocols). */export interface WebSocketUpgradeOptions {
4
+ import { isBun } from '../shared/runtime'
5
+ /** Options for `createWebSocketUpgrade` (supported subprotocols). */ export interface WebSocketUpgradeOptions {
6
6
  /** Subprotocols this server supports; the first client-requested match wins. */
7
7
  protocols?: ReadonlyArray<string>
8
8
  }
@@ -27,11 +27,7 @@
27
27
  import { rust } from '../rust-ffi'
28
28
  import { encoder, viewForArrayBuffer } from '../shared/bytes'
29
29
  import type { SchemaValidator } from '../shared/packed'
30
- import {
31
- createLruCache,
32
- createTickCoalescer,
33
- type LoadRequest,
34
- } from './batch'
30
+ import { createLruCache, createTickCoalescer, type LoadRequest } from './batch'
35
31
  import { createCostModel, nowNs } from './cost'
36
32
  import {
37
33
  fnvBatchKeysInto,
package/src/loader/ops.ts CHANGED
@@ -19,10 +19,10 @@
19
19
  // the loader never pays a conversion cost on the hot path. This module is the
20
20
  // single source of truth for what the loader can dispatch.
21
21
 
22
+ import type { PasswordHashOptions } from '../native'
22
23
  import { rust } from '../rust-ffi'
23
24
  import { addon } from '../rust-ffi/addon'
24
25
  import { decoder } from '../shared/bytes'
25
- import type { PasswordHashOptions } from '../native'
26
26
  import type { SchemaValidator } from '../shared/packed'
27
27
 
28
28
  /** Empty bytes sentinel for skip-on-error scalar parity (e.g. AEAD decrypt). */
@@ -9,7 +9,7 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
9
9
  import { SELFTEST_HEX, SELFTEST_JSON } from '../constants'
10
10
  import type { BunFFI, Raw3, Raw4, Raw5, Raw6, Raw8, Raw9, Raw10, RawCStr } from '../types'
11
11
  import type { BuildCtx } from './util'
12
- import { argon2PhcLength, cstr, flag, growExact, writeOrThrow } from './util'
12
+ import { allocOut, argon2PhcLength, cstr, flag, growExact, hasNul, writeOrThrow } from './util'
13
13
 
14
14
  /**
15
15
  * Build the codec/crypto/auth methods of the BunFFI surface. `ctx` is
@@ -34,7 +34,17 @@ export function buildCodecs(
34
34
  const validateUuid = sym.castrum_validate_uuid as (...a: unknown[]) => number | bigint
35
35
  const validateIpv4 = sym.castrum_validate_ipv4 as (...a: unknown[]) => number | bigint
36
36
  const validateIpv6 = sym.castrum_validate_ipv6 as (...a: unknown[]) => number | bigint
37
+ const validateEmailBytes = sym.castrum_validate_email_bytes as (
38
+ ...a: unknown[]
39
+ ) => number | bigint
40
+ const validateUuidBytes = sym.castrum_validate_uuid_bytes as (...a: unknown[]) => number | bigint
41
+ const validateIpv4Bytes = sym.castrum_validate_ipv4_bytes as (...a: unknown[]) => number | bigint
42
+ const validateIpv6Bytes = sym.castrum_validate_ipv6_bytes as (...a: unknown[]) => number | bigint
37
43
  const jsonSumRaw = sym.castrum_json_sum_ids as Raw4
44
+ const hexBatchRaw = sym.castrum_hex_validate_batch as Raw5
45
+ const hexBatchStrRaw = sym.castrum_hex_validate_batch_str as Raw4
46
+ const regexEscapeRaw = sym.castrum_regex_escape as Raw4
47
+ const regexEscapeStrRaw = sym.castrum_regex_escape_str as RawCStr
38
48
  const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
39
49
  const csrfVerify = sym.castrum_csrf_verify as Raw4
40
50
  const passwordVerify = sym.castrum_password_verify as Raw4
@@ -172,10 +182,67 @@ export function buildCodecs(
172
182
  },
173
183
  urlEncodeInto,
174
184
 
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,
185
+ // Verdict guards: the `cstring` form would truncate at an embedded NUL, so
186
+ // `"a@b.com\0<script>"` would be reported VALID — a verdict callers act on.
187
+ // No address / UUID / IP contains U+0000, so short-circuit without an FFI
188
+ // call (one `indexOf` scan, far cheaper than the encode fallback).
189
+ validateEmail: (input) => !hasNul(input) && Number(validateEmail(input)) === 1,
190
+ validateUuid: (input) => !hasNul(input) && Number(validateUuid(input)) === 1,
191
+ validateIpv4: (input) => !hasNul(input) && Number(validateIpv4(input)) === 1,
192
+ validateIpv6: (input) => !hasNul(input) && Number(validateIpv6(input)) === 1,
193
+ // Byte-input validators: `(ptr,len)` — zero transcode (no CString decode
194
+ // + engine re-encode like the cstring-ARG string forms above).
195
+ validateEmailBytes: (input) => Number(oneArg(validateEmailBytes, input)) === 1,
196
+ validateUuidBytes: (input) => Number(oneArg(validateUuidBytes, input)) === 1,
197
+ validateIpv4Bytes: (input) => Number(oneArg(validateIpv4Bytes, input)) === 1,
198
+ validateIpv6Bytes: (input) => Number(oneArg(validateIpv6Bytes, input)) === 1,
199
+ hexValidateBatchInto(input, width, output) {
200
+ // Batch fixed-width hex validation: NEWLINE-separated lines in; one
201
+ // verdict byte (1/0) per line out. Needed-size convention: `0` = bad
202
+ // width / null pointers (real error → throw); `> output.length` = the
203
+ // exact required size (caller grows once and retries). Width is
204
+ // validated here so an empty input can't mask it.
205
+ if (width === 0 || width > 4096) {
206
+ throw new Error('hex validate batch: width must be 1..=4096')
207
+ }
208
+ const w = Number(hexBatchRaw(input, lenOrView(input), width >>> 0, output, lenOrView(output)))
209
+ return w
210
+ },
211
+ regexEscapeInto(input, output) {
212
+ const w = Number(regexEscapeRaw(input, lenOrView(input), output, lenOrView(output)))
213
+ if (w === 0) {
214
+ throw new Error('regex escape: output buffer too small')
215
+ }
216
+ return w
217
+ },
218
+ // Zero-copy text path: cstring ARG in (engine-transcoded), cstring return
219
+ // out (engine-cloned) — the JS side does zero encode AND zero decode. A NUL
220
+ // would truncate the INPUT, so that case routes to the `(ptr,len)` sibling
221
+ // (exact length) instead of returning a silently shortened string.
222
+ regexEscapeStr: (input) => {
223
+ if (!hasNul(input)) return regexEscapeStrRaw(input)
224
+ const bytes = encodeUtf8(input)
225
+ const escaped = growExact(
226
+ (out) => Number(regexEscapeRaw(bytes, lenOrView(bytes), out, lenOrView(out))),
227
+ 16,
228
+ 1 << 20,
229
+ 'regex escape: output buffer too small',
230
+ )
231
+ return decodeUtf8(escaped)
232
+ },
233
+ hexValidateBatchStr(ids, width, output) {
234
+ if (width === 0 || width > 4096) {
235
+ throw new Error('hex validate batch: width must be 1..=4096')
236
+ }
237
+ // A NUL in the joined ids would truncate the line list, so a bad id could
238
+ // pass as its own prefix. Fall back to the byte form (exact length).
239
+ if (hasNul(ids)) {
240
+ const bytes = encodeUtf8(ids)
241
+ return Number(hexBatchRaw(bytes, lenOrView(bytes), width >>> 0, output, lenOrView(output)))
242
+ }
243
+ const w = Number(hexBatchStrRaw(ids, width >>> 0, output, lenOrView(output)))
244
+ return w
245
+ },
179
246
  jsonSumIds: (input) => {
180
247
  // Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
181
248
  // may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
@@ -202,19 +269,19 @@ export function buildCodecs(
202
269
  Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
203
270
 
204
271
  hexDecode(input) {
205
- const out = new Uint8Array(Math.floor(input.length / 2))
272
+ const out = allocOut(Math.floor(input.length / 2))
206
273
  const w = hexDecodeInto(input, out)
207
274
  return out.subarray(0, w)
208
275
  },
209
276
  hexDecodeInto,
210
277
  urlDecode(input) {
211
- const out = new Uint8Array(input.length)
278
+ const out = allocOut(input.length)
212
279
  const w = urlDecodeInto(input, out)
213
280
  return out.subarray(0, w)
214
281
  },
215
282
  urlDecodeInto,
216
283
  base64Decode(input, urlSafe, padding) {
217
- const out = new Uint8Array(Math.ceil((input.length * 3) / 4))
284
+ const out = allocOut(Math.ceil((input.length * 3) / 4))
218
285
  const w = base64DecodeInto(input, out, urlSafe, padding)
219
286
  return out.subarray(0, w)
220
287
  },
@@ -398,7 +465,7 @@ export function buildCodecs(
398
465
  },
399
466
  passwordHashBcrypt(password, cost) {
400
467
  // `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
401
- const out = new Uint8Array(64)
468
+ const out = allocOut(64)
402
469
  const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
403
470
  if (w === 0) {
404
471
  throw new Error('password hash bcrypt: output buffer too small')
@@ -409,7 +476,7 @@ export function buildCodecs(
409
476
  // Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
410
477
  // own buffer — so pre-clamp here so dkLen 0 still yields a 1-byte result.
411
478
  const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
412
- const out = new Uint8Array(dk)
479
+ const out = allocOut(dk)
413
480
  const w = Number(
414
481
  pbkdf2(
415
482
  password,
@@ -429,7 +496,7 @@ export function buildCodecs(
429
496
  },
430
497
  aeadEncrypt(key, nonce, plaintext, algorithm = 0) {
431
498
  // ciphertext + 16-byte auth tag.
432
- const out = new Uint8Array(plaintext.length + 16)
499
+ const out = allocOut(plaintext.length + 16)
433
500
  const w = Number(
434
501
  aeadEncrypt(
435
502
  key,
@@ -472,7 +539,7 @@ export function buildCodecs(
472
539
  return w
473
540
  },
474
541
  aeadDecrypt(key, nonce, ciphertext, algorithm = 0) {
475
- const out = new Uint8Array(ciphertext.length)
542
+ const out = allocOut(ciphertext.length)
476
543
  const w = Number(
477
544
  aeadDecrypt(
478
545
  key,
@@ -534,6 +601,17 @@ export function selfTestCodecs(b: BunFFI): boolean {
534
601
  ) {
535
602
  return false
536
603
  }
604
+ // Byte-input validator siblings: `(ptr,len)` parity with the string forms.
605
+ if (
606
+ !b.validateEmailBytes(enc.encode('a@b.com')) ||
607
+ !b.validateUuidBytes(enc.encode('550e8400-e29b-41d4-a716-446655440000')) ||
608
+ !b.validateIpv4Bytes(enc.encode('192.168.0.1')) ||
609
+ !b.validateIpv6Bytes(enc.encode('2001:db8::1')) ||
610
+ b.validateEmailBytes(enc.encode('not-an-email')) ||
611
+ b.validateIpv6Bytes(enc.encode('999'))
612
+ ) {
613
+ return false
614
+ }
537
615
  // Packed `[u8 ok][i64 sum LE]` ABI: legit zero-sum is ok, invalid input throws.
538
616
  if (b.jsonSumIds(enc.encode(`[{"id":1},{"id":2}]`)) !== 3n) return false
539
617
  if (b.jsonSumIds(enc.encode(`[{"id":0},{"id":0}]`)) !== 0n) return false
@@ -545,6 +623,46 @@ export function selfTestCodecs(b: BunFFI): boolean {
545
623
  }
546
624
  if (!sumInvalidThrew) return false
547
625
 
626
+ // Batch fixed-width hex validation (needed-size convention + verdicts).
627
+ {
628
+ const ids = enc.encode('507f1f77bcf86cd799439011\nzz\n507F1F77BCF86CD799439012')
629
+ const tiny = new Uint8Array(2)
630
+ const needed = b.hexValidateBatchInto(ids, 24, tiny)
631
+ if (needed !== 3) return false // exact required size on a too-small buffer
632
+ const out = new Uint8Array(needed)
633
+ if (b.hexValidateBatchInto(ids, 24, out) !== 3) return false
634
+ if (out[0] !== 1 || out[1] !== 0 || out[2] !== 1) return false
635
+ let widthThrew = false
636
+ try {
637
+ b.hexValidateBatchInto(ids, 0, new Uint8Array(3))
638
+ } catch {
639
+ widthThrew = true
640
+ }
641
+ if (!widthThrew) return false
642
+ }
643
+
644
+ // RegExp escaping: metachars get backslashes; plain text is untouched.
645
+ {
646
+ const out = new Uint8Array(64)
647
+ const w = b.regexEscapeInto(enc.encode('a.c(x)'), out)
648
+ if (w !== 9 || dec.decode(out.subarray(0, w)) !== 'a\\.c\\(x\\)') return false
649
+ const plain = new Uint8Array(16)
650
+ const wp = b.regexEscapeInto(enc.encode('hello'), plain)
651
+ if (wp !== 5 || dec.decode(plain.subarray(0, wp)) !== 'hello') return false
652
+ // too-small buffer reports the exact required size
653
+ if (b.regexEscapeInto(enc.encode('a.b'), new Uint8Array(2)) !== 4) return false
654
+ // zero-copy str sibling must produce the identical escaped string
655
+ if (b.regexEscapeStr('a.c(x)') !== 'a\\.c\\(x\\)') return false
656
+ if (b.regexEscapeStr('plain text') !== 'plain text') return false
657
+ }
658
+
659
+ // String-input batch hex validation (cstring ARG sibling).
660
+ {
661
+ const out = new Uint8Array(8)
662
+ const w = b.hexValidateBatchStr('507f1f77bcf86cd799439011\nzz', 24, out)
663
+ if (w !== 2 || out[0] !== 1 || out[1] !== 0) return false
664
+ }
665
+
548
666
  // HMAC RFC 4231 test case 1 (0x0b × 20 key, "Hi There" data).
549
667
  const hmacKey = new Uint8Array(20).fill(0x0b)
550
668
  const hmacData = enc.encode('Hi There')
@@ -29,7 +29,7 @@ export function buildCompress(
29
29
  sym: Record<string, (...a: unknown[]) => unknown>,
30
30
  ctx: BuildCtx,
31
31
  ): Partial<BunFFI> {
32
- const { lenOrView } = ctx
32
+ const { lenOrView, scratchFor } = ctx
33
33
 
34
34
  const gzipCompress = sym.castrum_gzip_compress as Raw5
35
35
  const gzipDecompress = sym.castrum_gzip_decompress as Raw5
@@ -40,15 +40,19 @@ export function buildCompress(
40
40
 
41
41
  return {
42
42
  jsonPatch(doc, patch) {
43
- return decodeUtf8(
44
- growExact(
45
- (out) =>
46
- Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out))),
47
- Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024),
48
- MAX_JSON_PATCH_OUTPUT,
49
- 'json patch: output buffer too small or patch inapplicable',
50
- ),
51
- )
43
+ // Pooled scratch — the patched doc is decoded to an immutable string
44
+ // synchronously (same call-scope contract as the cstring returns), so
45
+ // the bytes never need a fresh buffer. One exact retry on miss.
46
+ let out = scratchFor(Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024))
47
+ let w = Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out)))
48
+ if (w > out.length && w <= MAX_JSON_PATCH_OUTPUT) {
49
+ out = new Uint8Array(w)
50
+ w = Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out)))
51
+ }
52
+ if (w === 0 || w > out.length) {
53
+ throw new Error('json patch: output buffer too small or patch inapplicable')
54
+ }
55
+ return decodeUtf8(out.subarray(0, w))
52
56
  },
53
57
  gzipCompress(data, level = 6) {
54
58
  // The C ABI streams the compressed output directly into `out` (no internal
@@ -113,6 +117,14 @@ export function buildCompress(
113
117
  }
114
118
  return w
115
119
  },
120
+ /**
121
+ * gzip ISIZE probe → uncompressed size for a single-member stream, or `0`
122
+ * when unknown. Used to PRE-SIZE a destination buffer for the zero-copy
123
+ * task path (one pass, no intermediate buffer, no retry in the common case).
124
+ */
125
+ gzipIsize(data) {
126
+ return Number(gzipIsize(data, lenOrView(data)))
127
+ },
116
128
  brotliCompress(data, quality = 5) {
117
129
  // Same cap rationale as gzipCompress (streaming core); growExact for
118
130
  // incompressible data.
@@ -8,9 +8,9 @@
8
8
 
9
9
  import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
10
10
  import { EMPTY_VIEW } from '../constants'
11
- import type { BunFFI, Raw2, Raw3, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
11
+ import type { BunFFI, Raw2, Raw3, Raw4, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
12
12
  import type { BuildCtx } from './util'
13
- import { cstr, growExact, unpackRateCheck } from './util'
13
+ import { cstr, growExact, hasNul, unpackRateCheck } from './util'
14
14
 
15
15
  /**
16
16
  * Build the opaque-handle methods of the BunFFI surface. `ctx` is destructured
@@ -25,8 +25,9 @@ export function buildInstances(
25
25
  const conditionalIsNotModifiedRaw = sym.castrum_conditional_is_not_modified as Raw6
26
26
  const mediaTypeMatcherMatchesRaw = sym.castrum_media_type_matcher_matches as Raw3
27
27
  const acceptNegotiatorNegotiateRaw = sym.castrum_accept_negotiator_negotiate as RawCStr
28
- const acceptNegotiatorNegotiateServerRaw = sym
29
- .castrum_accept_negotiator_negotiate_server as (...a: unknown[]) => string | null
28
+ const acceptNegotiatorNegotiateServerRaw = sym.castrum_accept_negotiator_negotiate_server as (
29
+ ...a: unknown[]
30
+ ) => string | null
30
31
  const ed25519GenerateKeypairRaw = sym.castrum_ed25519_generate_keypair as Raw2
31
32
  const ed25519SignRaw = sym.castrum_ed25519_sign as Raw6
32
33
  const ed25519VerifyRaw = sym.castrum_ed25519_verify as Raw6
@@ -36,6 +37,10 @@ export function buildInstances(
36
37
  const jwtSignerVerifyRaw = sym.castrum_jwt_signer_verify as Raw6
37
38
  const templateRenderRaw = sym.castrum_template_render as Raw5
38
39
  const schemaValidatorValidateRaw = sym.castrum_schema_validator_validate as Raw3
40
+ const queryValidateSym = sym.castrum_query_validate as (...a: unknown[]) => number | bigint
41
+ const cookieValidateSym = sym.castrum_cookie_validate as (...a: unknown[]) => number | bigint
42
+ const sessionSealSym = sym.castrum_session_seal as (...a: unknown[]) => string | null
43
+ const sessionOpenSym = sym.castrum_session_open as Raw4
39
44
  const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
40
45
  const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
41
46
  const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
@@ -138,6 +143,27 @@ export function buildInstances(
138
143
  schemaValidatorValidate(inner, doc) {
139
144
  return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
140
145
  },
146
+ queryValidate(inner, qs) {
147
+ // `qs` is a cstring ARG — engine-transcoded (zero JS encode). A raw NUL is
148
+ // not a valid query byte, and it would TRUNCATE the arg so only a prefix
149
+ // got validated: a gate that says "this query passed" must not be foolable.
150
+ if (hasNul(qs)) return false
151
+ return Number(queryValidateSym(inner, qs)) === 1
152
+ },
153
+ cookieValidate(inner, header) {
154
+ // Same contract as `queryValidate`: a NUL would validate only a prefix.
155
+ if (hasNul(header)) return false
156
+ return Number(cookieValidateSym(inner, header)) === 1
157
+ },
158
+ sessionSeal(id, dataJson, expSecs, secret) {
159
+ // exp is an i64 C arg → BigInt.
160
+ return sessionSealSym(id, dataJson, BigInt(expSecs), secret)
161
+ },
162
+ sessionOpen(token, secret, output) {
163
+ // Needed-size convention; 0 = bad signature / malformed.
164
+ const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)))
165
+ return w
166
+ },
141
167
  rateLimiterCheck(inner, key, nowMs) {
142
168
  // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
143
169
  // Reused scratch + cached DataView (no per-call allocs). `key` is a
@@ -583,6 +609,22 @@ export function selfTestInstances(b: BunFFI): boolean {
583
609
  if (!urlBuilderThrew) {
584
610
  return false
585
611
  }
612
+ // Wire-validate: null inner (0) → false (ABI exercise); real parity lives
613
+ // in ffi.test.ts with a live compiled instance.
614
+ if (b.queryValidate(0, 'route=/a') !== false) return false
615
+ if (b.cookieValidate(0, 'route=/a') !== false) return false
616
+
617
+ // Session seal/open round trip through the C ABI.
618
+ {
619
+ const tok = b.sessionSeal('sess-9', '{"n":1}', 1234567, 'sekrit')
620
+ if (tok === null || !tok.includes('.') || !tok.startsWith('{"id":"sess-9"')) return false
621
+ const out = new Uint8Array(256)
622
+ const w = b.sessionOpen(tok, 'sekrit', out)
623
+ if (w <= 13 || out[0] !== 1) return false
624
+ // bad signature → 0
625
+ if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
626
+ }
627
+
586
628
  // rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
587
629
  let rlKeyThrew = false
588
630
  try {