castrum 0.9.4 → 0.9.7

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 (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,79 @@
1
+ // src/native/ffi/build/task.ts — off-thread task runtime BunFFI methods.
2
+ //
3
+ // The thinnest possible binding: `rust/task/` owns the pool, the result ring,
4
+ // and the batched doorbell. This file only adapts the `(ptr,len)` ABI pair
5
+ // (probe-gated `buffer`/`buffer_length` vs explicit lengths) and converts the
6
+ // `u64_fast` byte counts to numbers.
7
+ //
8
+ // See docs/RND-CONCURRENCY.md for the design and the measured costs.
9
+
10
+ import type { BunFFI, Raw0, Raw2, Raw4, Raw6 } from '../types'
11
+ import type { BuildCtx } from './util'
12
+
13
+ /** Raw 1-arg C-ABI symbol signature (a lone scalar arg or a bare pointer). */
14
+ type Raw1 = (a: unknown) => number | bigint
15
+
16
+ /**
17
+ * Build the off-thread task-runtime methods of the BunFFI surface. `ctx`
18
+ * supplies the probe-gated length adapter (`lenOrView`).
19
+ */
20
+ export function buildTask(
21
+ sym: Record<string, (...a: unknown[]) => unknown>,
22
+ ctx: BuildCtx,
23
+ ): Partial<BunFFI> {
24
+ const { lenOrView } = ctx
25
+
26
+ const init = sym.castrum_task_init as Raw1
27
+ const submit = sym.castrum_task_submit as Raw4
28
+ const submitOut = sym.castrum_task_submit_out as Raw6
29
+ const submitSlice = sym.castrum_task_submit_slice as Raw6
30
+ const drain = sym.castrum_task_drain as Raw2
31
+ const pending = sym.castrum_task_pending as Raw0
32
+ const cancel = sym.castrum_task_cancel as Raw1
33
+ const setDoorbell = sym.castrum_task_set_doorbell as Raw1
34
+ const shutdown = sym.castrum_task_shutdown as Raw0
35
+ const threads = sym.castrum_task_threads as Raw0
36
+
37
+ return {
38
+ taskInit: (n) => Number(init(n)),
39
+ // `args` is copied onto the worker by Rust, so the caller's pooled buffer
40
+ // may be released as soon as this returns.
41
+ taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
42
+ taskSubmitOut: (op, args, taskId, output) =>
43
+ Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
44
+ // Zero-copy INPUT: the header is copied by Rust, the payload is read in
45
+ // place — which is why the runtime must hold a reference to `data` for the
46
+ // whole task (see `Deferred.keep` in src/task/runtime.ts).
47
+ taskSubmitSlice: (op, hdr, data, taskId) =>
48
+ Number(submitSlice(op, hdr, lenOrView(hdr), data, lenOrView(data), taskId)),
49
+ taskDrain: (output) => Number(drain(output, lenOrView(output))),
50
+ taskPending: () => Number(pending()),
51
+ taskCancel: (taskId) => Number(cancel(taskId)),
52
+ taskSetDoorbell: (ptr) => Number(setDoorbell(ptr)),
53
+ taskShutdown: () => Number(shutdown()),
54
+ taskThreads: () => Number(threads()),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Bind-time self-test for the task symbols. The pool is intentionally NOT
60
+ * started here (no threads at import time) and no completion is produced;
61
+ * the full submit→drain→doorbell path is exercised by `test/unit/task/`.
62
+ */
63
+ export function selfTestTask(b: BunFFI): boolean {
64
+ try {
65
+ if (typeof b.taskPending() !== 'number') return false
66
+ if (typeof b.taskThreads() !== 'number' || b.taskThreads() < 0) return false
67
+ // `0` disables the doorbell — safe at bind time (nothing is in flight).
68
+ if (b.taskSetDoorbell(0) !== 1) return false
69
+ // An empty ring → 0; exercises the drain symbol without allocating anything.
70
+ if (typeof b.taskDrain(new Uint8Array(4)) !== 'number') return false
71
+ // NOT called: submitting here would start the pool threads at bind time.
72
+ // The full submit/submit_out path is exercised by test/unit/task/.
73
+ if (typeof b.taskSubmitOut !== 'function') return false
74
+ if (typeof b.taskSubmitSlice !== 'function') return false
75
+ return true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
@@ -42,6 +42,24 @@ export interface BuildCtx {
42
42
  /** Coerce an optional boolean to the C `u8` flag (1/0). */
43
43
  export const flag = (v?: boolean): number => (v ? 1 : 0)
44
44
 
45
+ /**
46
+ * Does this string contain a NUL (`U+0000`)?
47
+ *
48
+ * `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
49
+ * NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
50
+ * on the native side (Bun's docs describe the transcode; the truncation is the
51
+ * C convention it inherits). That is fine when the string is developer-supplied
52
+ * config or NUL-free by construction — and it is a real bug when the callee
53
+ * answers with a VERDICT (validators, query/cookie gates, batch validators: a
54
+ * truncated prefix passes where the full input fails) or when the exact bytes
55
+ * are the result (`regexEscape`, metric label values).
56
+ *
57
+ * Guard contract for every `cstring` ARG reachable from user input: either
58
+ * reject/short-circuit on NUL, or route to the `(ptr, usize)` byte form which
59
+ * carries the exact length. `test/unit/native/cstring-nul.test.ts` pins this.
60
+ */
61
+ export { hasNul } from '../../../shared/bytes'
62
+
45
63
  /**
46
64
  * Output-buffer allocation for the FFI wrappers. The native write fills
47
65
  * `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure
@@ -16,6 +16,7 @@ import { buildCompress } from './build/compress'
16
16
  import { buildInstances } from './build/instances'
17
17
  import { buildMetrics } from './build/metrics'
18
18
  import { buildParse } from './build/parse'
19
+ import { buildTask } from './build/task'
19
20
  import type { BuildCtx } from './build/util'
20
21
  import type { BunFFI } from './types'
21
22
 
@@ -92,5 +93,6 @@ export function build(
92
93
  ...buildParse(sym, ctx),
93
94
  ...buildInstances(sym, ctx),
94
95
  ...buildMetrics(sym, ctx),
96
+ ...buildTask(sym, ctx),
95
97
  } as BunFFI
96
98
  }
@@ -15,6 +15,7 @@ import { selfTestCompress } from './build/compress'
15
15
  import { selfTestInstances } from './build/instances'
16
16
  import { selfTestMetrics } from './build/metrics'
17
17
  import { selfTestParse } from './build/parse'
18
+ import { selfTestTask } from './build/task'
18
19
  import type { BunFFI } from './types'
19
20
 
20
21
  /** Verify every bound function against known-good results; false disables ffi. */
@@ -24,6 +25,7 @@ export function selfTest(b: BunFFI): boolean {
24
25
  selfTestCompress(b) &&
25
26
  selfTestParse(b) &&
26
27
  selfTestInstances(b) &&
27
- selfTestMetrics(b)
28
+ selfTestMetrics(b) &&
29
+ selfTestTask(b)
28
30
  )
29
31
  }
@@ -218,6 +218,12 @@ export interface BunFFI {
218
218
  gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
219
219
  /** gzip-decompress into `output`; returns bytes written (throws if too small). */
220
220
  gzipDecompressInto(data: Uint8Array, output: Uint8Array, maxDecompressed?: number): number
221
+ /**
222
+ * gzip ISIZE trailer probe → the uncompressed size for a single-member gzip
223
+ * stream, or `0` when unknown (invalid stream / multi-member). Used to
224
+ * PRE-SIZE a destination buffer for the zero-copy task path.
225
+ */
226
+ gzipIsize(data: Uint8Array): number
221
227
  /** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
222
228
  brotliCompress(data: Uint8Array, quality?: number): Uint8Array
223
229
  /** brotli-compress into `output`; returns bytes written (throws if too small). */
@@ -465,6 +471,23 @@ export interface BunFFI {
465
471
  * exact required size).
466
472
  */
467
473
  sessionOpen(token: string, secret: string, output: Uint8Array): number
474
+ /**
475
+ * Byte-arg `{@link sessionSeal}`: same envelope + wire format, inputs as
476
+ * bytes. Preferred for user-derived `id`/`dataJson`, because a `cstring` ARG
477
+ * is engine-transcoded (a per-arg cost) and SILENTLY TRUNCATES at an
478
+ * embedded `U+0000` before Rust sees it. `null` = failure.
479
+ */
480
+ sessionSealBytes(
481
+ id: Uint8Array,
482
+ dataJson: Uint8Array,
483
+ expSecs: number,
484
+ secret: Uint8Array,
485
+ ): string | null
486
+ /**
487
+ * Byte-arg `{@link sessionOpen}`: same output layout and needed-size
488
+ * convention, `token`/`secret` as bytes (exact length, no transcode).
489
+ */
490
+ sessionOpenBytes(token: Uint8Array, secret: Uint8Array, output: Uint8Array): number
468
491
 
469
492
  // ── Batch fixed-width hex validation / RegExp escaping ─────────────
470
493
  /**
@@ -488,6 +511,40 @@ export interface BunFFI {
488
511
  * cstring return out (engine-cloned — zero JS decode). `null` = failure.
489
512
  */
490
513
  regexEscapeStr(input: string): string | null
514
+
515
+ // ── Off-thread task runtime (castrum_task_*) ──────────────────────
516
+ /** Start the shared native task pool; 0 = started, 1 = already running. */
517
+ taskInit(threads: number): number
518
+ /** Submit a task op with packed `args` under `taskId`; 1 = accepted. */
519
+ taskSubmit(op: number, args: Uint8Array, taskId: number): number
520
+ /**
521
+ * Zero-copy submit: the op writes its result DIRECTLY into `output` on the
522
+ * pool thread. `output` must be a stable, caller-owned buffer that stays
523
+ * alive (and untouched) until the completion is drained — the runtime holds
524
+ * the reference. The completion body is the 8-byte LE written length.
525
+ */
526
+ taskSubmitOut(op: number, args: Uint8Array, taskId: number, output: Uint8Array): number
527
+ /**
528
+ * Zero-copy INPUT: only `hdr` is copied; `data` is read in place by the pool
529
+ * thread, so the caller must keep it alive (and untouched) until completion.
530
+ */
531
+ taskSubmitSlice(op: number, hdr: Uint8Array, data: Uint8Array, taskId: number): number
532
+ /**
533
+ * Drain finished tasks into `output` (packed `[u32 count][entry…]`).
534
+ * Returns bytes written, `0` when nothing is pending, or the EXACT required
535
+ * size when `output` is too small (nothing is consumed in that case).
536
+ */
537
+ taskDrain(output: Uint8Array): number
538
+ /** Finished-but-undrained completion count. */
539
+ taskPending(): number
540
+ /** Mark a task cancelled; 1 = newly marked, 0 = unknown/already finished. */
541
+ taskCancel(taskId: number): number
542
+ /** Register the thread-safe doorbell trampoline address (`0` disables it). */
543
+ taskSetDoorbell(ptr: number): number
544
+ /** Stop the task pool and drop pending completions. */
545
+ taskShutdown(): number
546
+ /** Configured task-pool worker count (0 before the pool starts). */
547
+ taskThreads(): number
491
548
  }
492
549
 
493
550
  /**
package/src/native/ffi.ts CHANGED
@@ -34,7 +34,7 @@ import type { BunFFI, FfiMode } from './ffi/types'
34
34
  // Static import: loader.ts has no side effects (path resolution only — no
35
35
  // dlopen), so this adds zero import-time cost while letting `bind()` reuse the
36
36
  // exact same resolved `.node` path the napi fallback uses (the shared seam).
37
- import { getAddonPath } from './loader'
37
+ import { getAddonPath, getAddonPathCandidates } from './loader'
38
38
 
39
39
  // Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
40
40
  // keeps working (existing call sites).
@@ -49,6 +49,12 @@ let cached: BunFFI | null | undefined
49
49
  */
50
50
  let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
51
51
 
52
+ /**
53
+ * The addon file the live `bun:ffi` binding was opened from, or `null` while
54
+ * unbound. Diagnostics only — a v3→baseline fallback is visible here.
55
+ */
56
+ let boundAddonPath: string | null = null
57
+
52
58
  // ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
53
59
 
54
60
  function resolveFfiMode(): FfiMode {
@@ -96,6 +102,20 @@ export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
96
102
  return bufferAbiMode
97
103
  }
98
104
 
105
+ /**
106
+ * The addon file path the live `bun:ffi` binding was opened from, or `null`
107
+ * when the transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or every
108
+ * candidate failed). Lazy — triggers the same one-time bind as
109
+ * {@link getBunFFI}. Use it to confirm WHICH artifact bound, e.g. when a stale
110
+ * v3 SIMD binary forced the baseline fallback.
111
+ */
112
+ export function ffiAddonPath(): string | null {
113
+ if (cached === undefined) {
114
+ getBunFFI()
115
+ }
116
+ return boundAddonPath
117
+ }
118
+
99
119
  /**
100
120
  * Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
101
121
  * `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
@@ -127,7 +147,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
127
147
  }
128
148
  }
129
149
 
130
- function bind(): BunFFI | null {
150
+ function bind(forcedPath?: string, tried: string[] = []): BunFFI | null {
131
151
  bufferAbiMode = null // reset — the mode below is only valid for a live bind
132
152
  const mode = resolveFfiMode()
133
153
  if (!isBun()) {
@@ -143,12 +163,13 @@ function bind(): BunFFI | null {
143
163
  if (mode === 'napi') {
144
164
  return null
145
165
  }
166
+ // Resolve the SAME addon file napi uses (./loader.ts — statically imported
167
+ // above; it only resolves the path, it never dlopens the addon). On a retry,
168
+ // `forcedPath` is the next candidate from `getAddonPathCandidates()`.
169
+ const path = forcedPath ?? getAddonPath()
146
170
  try {
147
171
  // eslint-disable-next-line @typescript-eslint/no-require-imports
148
172
  const { dlopen } = require('bun:ffi') as typeof import('bun:ffi')
149
- // Resolve the SAME addon file napi uses (./loader.ts — statically imported
150
- // above; it only resolves the path, it never dlopens the addon).
151
- const path = getAddonPath()
152
173
 
153
174
  // Probe `buffer`/`buffer_length` (the engine reads ptr + len off the SAME
154
175
  // TypedArray at call time — an atomic snapshot) once; when supported we use
@@ -209,6 +230,14 @@ function bind(): BunFFI | null {
209
230
  // The `_bytes` siblings take a `(ptr,len)` pair — zero transcode when
210
231
  // the caller already holds bytes (a cstring arg would force a decode +
211
232
  // engine re-encode; docs/FFI_BUN_GUIDE.md §3 shape 4).
233
+ //
234
+ // SAFETY RULE for every `cstring` ARG added here: the transcode is
235
+ // NUL-TERMINATED, so an embedded U+0000 truncates the value in-engine. A
236
+ // verdict op bound this way can be fooled (`validateEmail` on
237
+ // 'a@b.com\0<script>' returned TRUE before the byte re-route). Either
238
+ // give the op a `_bytes` sibling and route byte callers to it, or guard
239
+ // the string form with `hasNul` — see docs/FFI_BUN_GUIDE.md §6.1,
240
+ // pinned by test/unit/native/cstring-nul.test.ts.
212
241
  castrum_validate_email: { args: ['cstring'], returns: 'u8' },
213
242
  castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
214
243
  castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
@@ -547,6 +576,39 @@ function bind(): BunFFI | null {
547
576
  args: abi(['cstring', 'cstring', 'ptr', 'usize']),
548
577
  returns: U64_FAST,
549
578
  },
579
+ // Byte-arg siblings of the pair above: identical core and wire format,
580
+ // but every INPUT crosses as `(ptr,len)`. A `cstring` ARG costs the
581
+ // engine a transcode per arg AND silently truncates at an embedded
582
+ // U+0000 — reachable here because `id`/`data_json` are user-derived.
583
+ castrum_session_seal_bytes: {
584
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
585
+ returns: 'cstring',
586
+ },
587
+ castrum_session_open_bytes: {
588
+ args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
589
+ returns: U64_FAST,
590
+ },
591
+ // Off-thread task runtime: `submit` takes a packed `(ptr,len)` arg blob
592
+ // plus a JS-assigned id; `drain` writes the packed completion batch into
593
+ // a caller buffer (needed-size convention). `set_doorbell` takes the raw
594
+ // `JSCallback({threadsafe:true}).ptr` as a bare pointer (`abi` leaves a
595
+ // lone `ptr` untouched), so the pool can wake JS once per batch.
596
+ castrum_task_init: { args: ['u32'], returns: 'u32' },
597
+ castrum_task_submit: { args: abi(['u32', 'ptr', 'usize', 'usize']), returns: 'u32' },
598
+ castrum_task_submit_slice: {
599
+ args: abi(['u32', 'ptr', 'usize', 'ptr', 'usize', 'usize']),
600
+ returns: 'u32',
601
+ },
602
+ castrum_task_submit_out: {
603
+ args: abi(['u32', 'ptr', 'usize', 'usize', 'ptr', 'usize']),
604
+ returns: 'u32',
605
+ },
606
+ castrum_task_drain: { args: abi(['ptr', 'usize']), returns: U64_FAST },
607
+ castrum_task_pending: { args: [], returns: 'u32' },
608
+ castrum_task_cancel: { args: ['usize'], returns: 'u32' },
609
+ castrum_task_set_doorbell: { args: ['ptr'], returns: 'u32' },
610
+ castrum_task_shutdown: { args: [], returns: 'u32' },
611
+ castrum_task_threads: { args: [], returns: 'u32' },
550
612
  castrum_metrics_destroy: { args: ['usize'], returns: 'void' },
551
613
  })
552
614
 
@@ -557,20 +619,29 @@ function bind(): BunFFI | null {
557
619
  useBufferLength,
558
620
  )
559
621
  if (!selfTest(bindings)) {
560
- if (mode === 'ffi') {
561
- throw new Error(
562
- 'CASTRUM_FFI_MODE=ffi: the bun:ffi bind-time self-test failed, so the ' +
563
- 'primary transport cannot be trusted on this Bun/addon combination. ' +
564
- 'Unset CASTRUM_FFI_MODE (or use CASTRUM_FFI_MODE=auto) to fall back to napi.',
565
- )
566
- }
567
- return null
622
+ throw new Error(`bun:ffi bind-time self-test failed on ${path}`)
568
623
  }
624
+ boundAddonPath = path
569
625
  return bindings
570
626
  } catch (err) {
627
+ bufferAbiMode = null
628
+ // The preferred artifact failed to dlopen/bind/self-test. The common cause
629
+ // is a STALE v3 SIMD binary (`castrum.linux-x64-v3-gnu.node`) that predates
630
+ // a newly added `castrum_*` symbol: dlopen fails, and without this walk the
631
+ // whole FFI transport would be disabled (every call silently downgraded to
632
+ // napi, and the task runtime silently made synchronous). Try the remaining
633
+ // existing candidates — the baseline binary — before giving up.
634
+ for (const next of getAddonPathCandidates()) {
635
+ if (next === path || tried.includes(next)) continue
636
+ const bound = bind(next, [...tried, path])
637
+ if (bound) return bound
638
+ }
571
639
  if (mode === 'ffi') {
572
640
  const cause = err instanceof Error ? `: ${err.message}` : ''
573
- throw new Error(`CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause}`)
641
+ throw new Error(
642
+ `CASTRUM_FFI_MODE=ffi: failed to bind bun:ffi${cause} ` +
643
+ `(tried: ${[...tried, path].join(', ')})`,
644
+ )
574
645
  }
575
646
  return null
576
647
  }
@@ -67,15 +67,16 @@ export function supportsX8664V3(cpuinfo?: string): boolean {
67
67
  * @param arch `process.arch`
68
68
  * @param cpuinfo optional `/proc/cpuinfo` text (tests inject it; the real call
69
69
  * omits it and reads the file lazily on linux/x64 only)
70
- * @returns the first existing candidate path (throws if none exists)
70
+ * @returns every candidate path, in preference order (v3 SIMD first on a
71
+ * capable linux/x64 host, then the baseline names)
71
72
  */
72
- export function resolveAddonPathFrom(
73
+ export function addonCandidatesFrom(
73
74
  envOverride: string | undefined,
74
75
  baseDir: string,
75
76
  platform: string,
76
77
  arch: string,
77
78
  cpuinfo?: string,
78
- ): string {
79
+ ): string[] {
79
80
  // napi-rs artifact naming: castrum.<platform>-<arch>[-<libc>].node
80
81
  // e.g. linux-x64-gnu, linux-x64-musl, win32-x64-msvc, darwin-arm64.
81
82
  const libcVariants =
@@ -156,6 +157,22 @@ export function resolveAddonPathFrom(
156
157
  candidates.push(join(baseDir, '..', '..', 'target', 'release', name))
157
158
  }
158
159
 
160
+ return candidates
161
+ }
162
+
163
+ /**
164
+ * Resolve the addon path: the FIRST existing candidate from
165
+ * {@link addonCandidatesFrom} (v3 SIMD first on a capable linux/x64 host).
166
+ * Throws when nothing exists.
167
+ */
168
+ export function resolveAddonPathFrom(
169
+ envOverride: string | undefined,
170
+ baseDir: string,
171
+ platform: string,
172
+ arch: string,
173
+ cpuinfo?: string,
174
+ ): string {
175
+ const candidates = addonCandidatesFrom(envOverride, baseDir, platform, arch, cpuinfo)
159
176
  for (const candidate of candidates) {
160
177
  if (existsSync(candidate)) {
161
178
  return candidate
@@ -169,6 +186,39 @@ export function resolveAddonPathFrom(
169
186
  )
170
187
  }
171
188
 
189
+ /** Cached result of {@link getAddonPathCandidates} (existing files only). */
190
+ let cachedCandidates: string[] | undefined
191
+
192
+ /**
193
+ * Every EXISTING addon candidate, in preference order (v3 SIMD first on a
194
+ * capable linux/x64 host, then the baseline names).
195
+ *
196
+ * The bun:ffi binder walks this list so a stale or partial *preferred* artifact
197
+ * — e.g. a `castrum.linux-x64-v3-gnu.node` built before a newly added
198
+ * `castrum_*` symbol — falls back to the baseline binary instead of silently
199
+ * disabling the whole FFI transport (which would downgrade every native call to
200
+ * napi and make the off-thread task runtime run synchronously).
201
+ */
202
+ export function getAddonPathCandidates(): string[] {
203
+ if (cachedCandidates) return cachedCandidates
204
+ const all = addonCandidatesFrom(
205
+ resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
206
+ dirname(fileURLToPath(import.meta.url)),
207
+ process.platform,
208
+ process.arch,
209
+ )
210
+ const seen = new Set<string>()
211
+ const existing: string[] = []
212
+ for (const candidate of all) {
213
+ if (!seen.has(candidate) && existsSync(candidate)) {
214
+ seen.add(candidate)
215
+ existing.push(candidate)
216
+ }
217
+ }
218
+ cachedCandidates = existing
219
+ return existing
220
+ }
221
+
172
222
  function resolveAddonPath(): string {
173
223
  return resolveAddonPathFrom(
174
224
  resolveEnvVar('CASTRUM_NATIVE_LIBRARY_PATH', ['NAPI_RS_NATIVE_LIBRARY_PATH']),
@@ -11,12 +11,19 @@
11
11
 
12
12
  import type { EncodingPrefResult, MediaTypeResult } from '../../native'
13
13
  import type { BunFFI } from '../../native/ffi'
14
- import { decoder } from '../../shared/bytes'
14
+ import { decoder, hasNul } from '../../shared/bytes'
15
15
  import { decodeUtf8Fatal, decodeUtf8Range } from '../../shared/codec'
16
16
  import { packPairs } from '../../shared/packed'
17
17
  import { memoizeFfi, type RustClientContext, resolveNative } from '../context'
18
18
  import { writeInto } from '../into'
19
19
 
20
+ /**
21
+ * RFC 6455 `Sec-WebSocket-Accept` is always 28 chars. Reused across calls
22
+ * because the value is decoded to a string synchronously before returning —
23
+ * the same discipline as the other pooled scratches in this layer.
24
+ */
25
+ const WS_ACCEPT_SCRATCH = new Uint8Array(64)
26
+
20
27
  // Mirror napi `url_decode`'s UTF-8 validation (rust/http/url_codec.rs): only
21
28
  // validate when a high byte is present (the ASCII fast path skips it — same as
22
29
  // Rust's saw_high check). On the Bun path the check is a native C-ABI probe
@@ -144,11 +151,22 @@ export function buildHttp(ctx: RustClientContext) {
144
151
 
145
152
  return {
146
153
  mimeFromExtension(ext: Uint8Array): Uint8Array | string {
147
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
148
- // (cheap CString clone) before the call. napi keeps the memoized-bytes
149
- // path (cachedMime).
154
+ // FFI-first. This op has no `*_bytes` sibling, so the bytes must be decoded
155
+ // for the `cstring` ARG — the decode is the price of the cstring form, not
156
+ // a speed trick (route new byte-shaped ops to a byte sibling instead; see
157
+ // docs/FFI_BUN_GUIDE.md §6.1). napi keeps the memoized-bytes path
158
+ // (cachedMime).
159
+ //
160
+ // A NUL would truncate the DECODED string native-side, so `.js\0evil`
161
+ // would resolve as `.js` — a Content-Type from a prefix. An extension
162
+ // containing a NUL is not an extension, so it takes the unknown sentinel
163
+ // (the guard is `indexOf` on an already-decoded string: ~4 ns).
150
164
  const f = ffi()
151
- if (f) return f.mimeFromExtension(decoder.decode(ext)) ?? 'application/octet-stream'
165
+ if (f) {
166
+ const name = decoder.decode(ext)
167
+ if (hasNul(name)) return 'application/octet-stream'
168
+ return f.mimeFromExtension(name) ?? 'application/octet-stream'
169
+ }
152
170
  return ctx.cachedMime(ext)
153
171
  },
154
172
  urlEncode(input: Uint8Array): Uint8Array | string {
@@ -188,34 +206,54 @@ export function buildHttp(ctx: RustClientContext) {
188
206
  return n('urlDecodeInto')(input, output) as number
189
207
  },
190
208
  validateEmail(input: Uint8Array): boolean {
191
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
192
- // (cheap CString clone) before the call — still faster than the old
193
- // `(ptr,len)` view path (see bench:margin cstring scenario). napi
194
- // fallback keeps the byte path.
209
+ // FFI-first. The bytes cross as a `buffer`/`buffer_length` pair (an atomic
210
+ // ptr+len snapshot, zero transcode) rather than being decoded to a JS
211
+ // string for the `cstring` ARG. Measured on Bun 1.4.2 over 400k calls:
212
+ // 101 ns vs 234 ns (email), 65 vs 164 (uuid), 31 vs 138 (ipv4) — and the
213
+ // byte form cannot truncate at an embedded NUL the way a cstring ARG
214
+ // does (a NUL byte used to make "a@b.com\0<script>" validate as TRUE).
195
215
  const f = ffi()
196
- if (f) return f.validateEmail(decoder.decode(input))
216
+ if (f) return f.validateEmailBytes(input)
197
217
  return n('validateEmail')(input) as boolean
198
218
  },
199
219
  validateUuid(input: Uint8Array): boolean {
200
220
  const f = ffi()
201
- if (f) return f.validateUuid(decoder.decode(input))
221
+ if (f) return f.validateUuidBytes(input)
202
222
  return n('validateUuid')(input) as boolean
203
223
  },
204
224
  validateIpv4(input: Uint8Array): boolean {
205
225
  const f = ffi()
206
- if (f) return f.validateIpv4(decoder.decode(input))
226
+ if (f) return f.validateIpv4Bytes(input)
207
227
  return n('validateIpv4')(input) as boolean
208
228
  },
209
229
  validateIpv6(input: Uint8Array): boolean {
210
230
  const f = ffi()
211
- if (f) return f.validateIpv6(decoder.decode(input))
231
+ if (f) return f.validateIpv6Bytes(input)
212
232
  return n('validateIpv6')(input) as boolean
213
233
  },
214
234
  wsAcceptKey(key: Uint8Array): Uint8Array | string {
215
- // FFI-first: the `cstring` ARG is string-only, so bytes are decoded once
216
- // (cheap CString clone) before the call. napi fallback keeps the bytes.
235
+ // FFI-first: the `cstring` ARG + cstring return is the fast path (no JS
236
+ // encode, engine-cloned result) — end-to-end it measures ~367 ns, while
237
+ // routing every call through the byte sibling costs ~381 ns (the extra
238
+ // `subarray` + decode outweighs the transcode saved; measured over 300k
239
+ // calls on Bun 1.4.2 through THIS function, not the raw binding).
240
+ //
241
+ // Correctness rider: a NUL would truncate the `cstring` ARG, so the accept
242
+ // value would be computed for a PREFIX of the key — verified:
243
+ // `'dGhlIHNhbXBsZSBub25jZQ==\0junk'` returned exactly the value for the
244
+ // key WITHOUT the suffix. That case takes the byte sibling (exact length).
217
245
  const f = ffi()
218
- if (f) return f.wsAcceptKey(decoder.decode(key))
246
+ if (f) {
247
+ const text = decoder.decode(key)
248
+ if (!hasNul(text)) return f.wsAcceptKey(text)
249
+ const w = f.wsAcceptKeyInto(key, WS_ACCEPT_SCRATCH)
250
+ if (w > 0 && w <= WS_ACCEPT_SCRATCH.length) {
251
+ return decoder.decode(WS_ACCEPT_SCRATCH.subarray(0, w))
252
+ }
253
+ // Unreachable for a live view (the native side writes 28 for any key);
254
+ // the napi path is byte-exact, so it can never resurrect truncation.
255
+ return n('wsAcceptKey')(key) as Uint8Array | string
256
+ }
219
257
  return n('wsAcceptKey')(key) as Uint8Array | string
220
258
  },
221
259
  wsAcceptKeyInto(key: Uint8Array, output: Uint8Array): number {
@@ -1,16 +1,27 @@
1
1
  // src/rust-ffi/text.ts — String-oriented FFI namespace (text in → text out).
2
2
  //
3
3
  // `rust.text.*` is the ergonomic string API over the byte-level native
4
- // functions. Pure wrappers: encode input, call native, decode output.
4
+ // functions. MOST of it is FFI-first with `cstring` args/returns: the engine
5
+ // transcodes a JS string in-call, so there is no JS-side encode at all (and
6
+ // byte-arg ops are not involved). Encode/decode wrappers exist only for the
7
+ // napi fallback and for ops with no cstring form — plus a NUL guard wherever a
8
+ // `cstring` ARG could truncate a verdict or a byte-exact value
9
+ // (docs/FFI_BUN_GUIDE.md §6.1).
5
10
  //
6
11
  // Runtime dispatch is centralized in the adapter (`ctx.runtime`): the Bun
7
12
  // built-in delegations (urlEncode/urlDecode) come from `builtins.has(op)` and
8
13
  // the native call from `transport.ffi` / `transport.resolve` (bun:ffi first,
9
14
  // napi fallback) — no inline `isBun()` / `getBunFFI()`.
10
15
 
11
- import { decoder, encoder } from '../shared/bytes'
16
+ import { decoder, encoder, hasNul } from '../shared/bytes'
12
17
  import { type RustClientContext, resolveNative } from './context'
13
18
 
19
+ /**
20
+ * Scratch for the byte-path `Sec-WebSocket-Accept` fallback (28 chars; the
21
+ * NUL case only). Decoded synchronously before returning, so reuse is safe.
22
+ */
23
+ const WS_ACCEPT_OUT = new Uint8Array(64)
24
+
14
25
  /** String-oriented FFI namespace. */
15
26
  export interface RustText {
16
27
  /**
@@ -91,8 +102,19 @@ export function buildText(ctx: RustClientContext): RustText {
91
102
  // FFI-first: `cstring` ARG + cstring return (native transfer both ways —
92
103
  // the engine transcodes the key in-engine, no JS encode). napi keeps the
93
104
  // encode/decode path.
105
+ //
106
+ // Exception: a NUL would truncate the `cstring` ARG, so the accept value
107
+ // would be computed for a PREFIX of the key. Encode and take the byte path
108
+ // in that (practically unreachable) case: still a wrong key for a client
109
+ // that sent a NUL, but wrong for the bytes the caller actually passed.
94
110
  const f = transport.ffi
95
- if (f) return f.wsAcceptKey(key)
111
+ if (f) {
112
+ if (!hasNul(key)) return f.wsAcceptKey(key)
113
+ const w = f.wsAcceptKeyInto(encoder.encode(key), WS_ACCEPT_OUT)
114
+ return w > 0 && w <= WS_ACCEPT_OUT.length
115
+ ? decoder.decode(WS_ACCEPT_OUT.subarray(0, w))
116
+ : ''
117
+ }
96
118
  return decoder.decode(resolveNative(ctx, 'wsAcceptKey')(encoder.encode(key)) as Uint8Array)
97
119
  },
98
120
  validateEmail(input) {
@@ -134,3 +134,22 @@ export function toPlainBuffer(bytes: Uint8Array): Uint8Array {
134
134
  copy.set(bytes)
135
135
  return copy
136
136
  }
137
+
138
+ /**
139
+ * Does this string contain a NUL (`U+0000`)?
140
+ *
141
+ * `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
142
+ * NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
143
+ * natively (Bun's docs describe the transcode; the truncation is the C
144
+ * convention it inherits). That is fine for developer-supplied config, and a
145
+ * real bug wherever the callee answers with a VERDICT (a truncated prefix
146
+ * passes where the full input fails) or where the exact bytes ARE the result.
147
+ *
148
+ * Cheap: one `indexOf` scan, ~4 ns for typical inputs.
149
+ *
150
+ * @param s - The string to check.
151
+ * @returns `true` when a NUL is present.
152
+ */
153
+ export function hasNul(s: string): boolean {
154
+ return s.indexOf('\u0000') !== -1
155
+ }