castrum 0.9.2 → 0.9.4

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 (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
package/docs/API.md CHANGED
@@ -38,7 +38,7 @@ runtimes, and binary ops (`gzip*`, `brotli*`, `aead*`, `pbkdf2Sha256`,
38
38
  | `proven`, `PROVEN_SELECTION`, `provenImpl`, `provenStatus`, `isProven`, `provenSurface`, `provenSummary` | The full `rust.*` surface + the baked (pure-data) proven-selection registry of benchmark winners (`native` / `js` / `bun`). |
39
39
  | `opImpl`, `isNativeOp`, `opDecision` | Native-vs-JS selection hints (benchmark-driven). |
40
40
  | `loader`, `createLoader` | Higher-order loader over the batch surface. |
41
- | `encoder`, `decoder` | Codec-backed UTF-8 helpers (Bun: `Bun.ArrayBufferSink` / `CString`; Node: `TextEncoder`/`TextDecoder`). `encode`/`decode` accept the `Uint8Array | string` union and normalize. |
41
+ | `encoder`, `decoder` | Codec-backed UTF-8 helpers (shared native `TextEncoder` / Bun: `CString`; Node: `TextEncoder`/`TextDecoder`). `encode`/`decode` accept the `Uint8Array | string` union and normalize. |
42
42
  | `uuidv7()` | UUIDv7 — `Bun.randomUUIDv7` on Bun, `crypto.randomUUID` on Node. |
43
43
  | `AdaptiveEstimate`, `AdaptiveEstimateOptions` | Bounded EWMA adaptive-estimate utility. |
44
44
  | `createMetrics`, `DEFAULT_BUCKETS` | Zero-dependency metrics registry (counters/gauges/histograms + Prometheus text). |
@@ -31,8 +31,9 @@ The bridge between TypeScript and Rust has **two transports**, both loading the
31
31
  cdylib (built with napi-rs):
32
32
 
33
33
  - **`bun:ffi` (PRIMARY on Bun)** — `src/native/ffi.ts` `dlopen`s the addon's
34
- `extern "C"` exports (`rust/ffi/`, 85 `castrum_*` symbols — 77 direct + 4
35
- `validator_c_abi!` + 4 `compress_to_out!`, parity guarded by
34
+ `extern "C"` exports (`rust/ffi/`, 109 `castrum_*` symbols — 97 direct + 4
35
+ `validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`,
36
+ parity guarded by
36
37
  `test/unit/native/ffi-symbol-parity.test.ts`) and Bun JIT-calls them
37
38
  directly (~10-20ns crossing vs ~100-350ns N-API). A bind-time self-test guards
38
39
  correctness: on ANY failure the layer is disabled and calls fall back to napi.
@@ -42,8 +42,8 @@ as a pre-baked pipeline, and still be usable from both runtimes.
42
42
  ### 2.1 One cdylib, two transports
43
43
 
44
44
  The addon is built with napi-rs (so Node can load it), but it *also* exports
45
- `extern "C"` symbols (`rust/ffi/`, **85 `castrum_*` symbols** — 77 direct + 4
46
- `validator_c_abi!` + 4 `compress_to_out!`) so Bun can
45
+ `extern "C"` symbols (`rust/ffi/`, **109 `castrum_*` symbols** — 97 direct + 4
46
+ `validator_c_abi!` + 4 `validator_bytes_c_abi!` + 4 `compress_to_out!`) so Bun can
47
47
  `dlopen` it directly:
48
48
 
49
49
  - **`bun:ffi` (primary on Bun)** — Bun JIT-calls the C-ABI exports at
@@ -72,6 +72,22 @@ whether the ffi transport is live.
72
72
  |----------|------|---------|-------------|
73
73
  | `CASTRUM_PUBLISH_ALLOW_PARTIAL` | bool | — | When `1`, `prepublishOnly` ships a tarball containing only the locally-built platforms instead of failing on the full `napi.targets` set. Set automatically by `bun run publish:manual`; **never** set it for a normal `npm publish`.
74
74
 
75
+ ## Postinstall fallback build (`scripts/postinstall.mjs`)
76
+
77
+ The package ships prebuilt addons for every `napi.targets` platform (built by
78
+ the CI "build" job on push/PR and staged into the tarball on a `v*` tag push).
79
+ `postinstall` is a fast no-op when a matching prebuilt is present. When none
80
+ ships for the host (an exotic platform / libc, or a partial tarball), it
81
+ compiles the addon **from source** in the consumer's `node_modules` via
82
+ `cargo build --release` (needs a Rust toolchain + crates.io access).
83
+
84
+ | Variable | Type | Default | Description |
85
+ |----------|------|---------|-------------|
86
+ | `CASTRUM_SKIP_BUILD` | (presence) | — | Skip the source-build fallback entirely (warn and continue). Use when a Rust toolchain is intentionally unavailable; the runtime loader will then fail with a clear "build it" error if the addon is needed. |
87
+ | `CASTRUM_REQUIRE_BUILD` | (presence) | — | Turn every postinstall "warn and skip" (missing toolchain, build failure, missing cdylib) into a hard install failure — for environments that must guarantee a loadable addon. |
88
+ | `npm_config_build_from_source` | bool | — | Set by `npm install --build-from-source`; forces a rebuild even when a prebuilt artifact is present. |
89
+ | `CI` | bool | — | GitHub Actions (and other CI) set this; the postinstall skips the fallback build because CI builds the addon explicitly (`bun run build`). |
90
+
75
91
  ## Security guidance
76
92
 
77
93
  - **Proxy trust**: `trustProxy: true` is deprecated — it trusts EVERY hop of
@@ -99,14 +99,23 @@ the matching shape and nothing else:
99
99
  (no copy). **Benchmark-gated** (`bench:margin` `cstringArg` scenario):
100
100
  ~40–52 ns/call vs ~209–233 ns for `encodeUtf8` + `(ptr,len)` — a ~76–82% win,
101
101
  including non-ASCII inputs. It is a LOSS for BYTE inputs (decode + re-encode)
102
- — keep `(ptr,len)` there. **Verified `bun:ffi` facts**: JS `null` → NULL
103
- pointer, and JS `''` (empty string) → NULL too — a present-but-empty value is
104
- indistinguishable from absent through a `cstring` arg (so SSE's `Option`
105
- event/id semantics stay on `(ptr,len)`). Adopted by: the validators
106
- (`castrum_validate_*`), `castrum_ws_accept_key`, `castrum_mime_from_extension`,
102
+ — keep `(ptr,len)` there (the validators ship BOTH shapes:
103
+ `castrum_validate_*` for strings, `castrum_validate_*_bytes` for bytes).
104
+ **Verified `bun:ffi` facts**: JS `null` → NULL pointer, and JS `''` (empty
105
+ string) → NULL too — a present-but-empty value is indistinguishable from
106
+ absent through a `cstring` arg UNLESS a presence flag crosses alongside it.
107
+ Adopted by: the validators (`castrum_validate_*`),
108
+ `castrum_ws_accept_key`, `castrum_mime_from_extension`,
107
109
  `castrum_password_verify_bcrypt` (phc), `castrum_rate_limiter_check` (key),
108
110
  and `castrum_ingress_handle_components` (url/ip).
109
111
 
112
+ > **CASE STUDY — a conversion we REVERTED (2026-08-23)**: SSE's event/id
113
+ > were converted to `cstring` ARGs (+ presence flags) and shipped briefly,
114
+ > then reverted. The conversion itself was sound; what failed was the dev
115
+ > loop — see §14. Every "bun:ffi is broken" signal during that episode
116
+ > traced back to a mismatched addon↔JS state, never to the engine. Do not
117
+ > cite this as an engine bug.
118
+
110
119
  Opaque instance handles are passed as a **bare `usize`** (`inner: usize`), and
111
120
  must be null-checked at the top of the function (return the `0` sentinel) —
112
121
  castrum's `castrum_ingress_handle_packed` / `castrum_*_matcher_matches` etc.
@@ -328,8 +337,82 @@ Rust-based runtime family. Castrum's own measurements live in `bench/`.
328
337
  **Verify after any FFI change**:
329
338
 
330
339
  ```bash
331
- nm -D --defined-only <addon> | grep -c castrum_ # = 85
340
+ nm -D --defined-only <addon> | grep -c castrum_ # = 109
332
341
  bun run bench:http:smoke # after touching decoders/handlers
333
342
  bun test test/unit/native/ffi.test.ts # FFI↔napi parity + self-test
334
343
  bun test test/unit/native/ffi-symbol-parity.test.ts
335
344
  ```
345
+
346
+ ---
347
+
348
+ ## 14. Changing an existing symbol's ABI — the desync hazard (post-mortem 2026-08-23)
349
+
350
+ Adding or re-signing a `castrum_*` symbol means touching **Rust and the JS
351
+ dlopen map in lockstep**. `bun:ffi` performs ZERO runtime validation of the
352
+ declared ABI: if the addon on disk and `src/native/ffi.ts` disagree — arity,
353
+ arg order, or arg type — the call still crosses, the callee reads your scalar
354
+ as a pointer (or vice versa), and the process **segfaults**. This is not a
355
+ Bun bug; it is inherent to raw FFI, and it is why the bind-time self-test is
356
+ mandatory.
357
+
358
+ ### The 2026-08-23 incident (recorded so nobody repeats it)
359
+
360
+ Converting `castrum_sse_encode_into`'s event/id to `cstring` args triggered
361
+ repeated SIGSEGVs that looked like an engine bug ("crash at address 0x5/0x6",
362
+ flaky across runs, "raw bindings pass but the package crashes"). After a full
363
+ root-cause pass, **every** crash traced to a development-loop desync — never
364
+ to bun:ffi itself:
365
+
366
+ 1. **Half-updated trees**: rebuilding the Rust side while the JS map (or a
367
+ scratch probe) still declared the old signature — e.g. after `git stash`
368
+ / `git checkout` of one side. The callee then dereferenced a small integer
369
+ argument as a pointer: crash addresses literally equalled values passed in
370
+ earlier calls (`0x5`, `0x6`).
371
+ 2. **Probe methodology bugs**: shell probes treated ANY non-zero exit as a
372
+ crash — including clean `TypeError: Symbol "<key>" not found` failures
373
+ from a misnamed dlopen-map key. Several "crashes" were exit-code-1
374
+ TypeErrors.
375
+ 3. **A mid-session runtime auto-update** (Bun canary replaced under us),
376
+ invalidating comparisons across runs.
377
+
378
+ The minimal C-dylib control experiment bound every suspect signature shape
379
+ (`cstring` leading/middle/trailing, mixed with `buffer`/`buffer_length`,
380
+ `u64_fast` returns) against trivial exports: all marshaled correctly on Bun
381
+ 1.4.0/1.4.1 canaries. **No engine bug exists to report.**
382
+
383
+ ### Rules for any ABI change (signature, arg order, or type)
384
+
385
+ 1. **Treat Rust signature + JS dlopen-map entry + JS wrapper as ONE commit.**
386
+ Never run anything that binds (`bun test`, benches, `import castrum`)
387
+ between editing one side and the other.
388
+ 2. **Rebuild before you bind**: after ANY `rust/ffi/**` edit (including
389
+ reverting one via git), run `bun run build` before running tests. A stale
390
+ `castrum.*.node` next to fresh JS is a guaranteed segfault, not an error.
391
+ 3. **Fail loudly while debugging**: use `CASTRUM_FFI_MODE=ffi`. In `auto`
392
+ mode a failed bind/self-test silently falls back to napi — which masks
393
+ breakage as mysterious slowness or wrong-path results instead of an error.
394
+ 4. **Know the failure signatures**:
395
+ - `SIGSEGV at a tiny address (0x5–0xB)` right after a binding → almost
396
+ always an ABI mismatch; check `git status rust/ src/native/ffi.ts` and
397
+ rebuild before theorizing about the engine.
398
+ - `Symbol "<name>" not found` at dlopen → key/name drift (parity test's
399
+ domain).
400
+ - ffi silently off → self-test returned false; run with
401
+ `CASTRUM_FFI_MODE=ffi` to get the throw instead of the fallback.
402
+ 5. **Known guard gap**: `test/unit/native/ffi-symbol-parity.test.ts` checks
403
+ symbol NAMES (rust exports ⊆ dlopen map ⊆ self-test), NOT arities/types.
404
+ Arity drift surfaces only at call time — as a crash. When changing a
405
+ signature, update BOTH sides plus the self-test vectors in the same edit,
406
+ and let `bun test test/unit/native/ffi.test.ts` (FFI↔napi parity) catch
407
+ value divergence.
408
+ 6. **Probe discipline when investigating**: distinguish crash from failure
409
+ explicitly (exit code 139 + `Segmentation fault` line vs exit 1 +
410
+ stderr), verify `bun --version` before AND after long sessions (canary
411
+ auto-updates invalidate comparisons), and test hypotheses against a
412
+ minimal dylib before blaming the engine.
413
+ 7. **No repo-wide auto-fixers mid-investigation**: `bun run lint:fix`
414
+ rewrites import order across ~90 files; in this codebase import ORDER
415
+ carries side-effect semantics (eager-dlopen contract). Scope formatters
416
+ to your changed files, and treat mass diffs appearing during a debug
417
+ session as a variable to eliminate (revert them) before drawing
418
+ conclusions.
package/docs/REPO_MAP.md CHANGED
@@ -169,8 +169,8 @@ rust/ ONE cdylib crate (Cargo [lib] → lib.rs).
169
169
  native stack), options/time/packed, cors, proxy, ip_trust, rate_limit,
170
170
  terminal, output.rs (single numeric layout source),
171
171
  ingress_constants.rs (napi projection).
172
- ffi/ #[no_mangle] extern "C" exports (85 castrum_* symbols — 77 direct + 4
173
- validator_c_abi! + 4 compress_to_out!; parity guarded by
172
+ ffi/ #[no_mangle] extern "C" exports (109 castrum_* symbols — 97 direct + 4
173
+ validator_c_abi! + 4 validator_bytes_c_abi! + 4 compress_to_out!; parity guarded by
174
174
  test/unit/native/ffi-symbol-parity.test.ts) for Bun's bun:ffi
175
175
  primary transport, incl. castrum_ingress_layout (the layout blob).
176
176
  mod.rs (module map) + util.rs (panic_guard / HMAC cache / cstring helpers)
package/index.ts CHANGED
@@ -11,50 +11,44 @@
11
11
  // load. Do NOT add more eager-native modules; keep everything else lazy (see
12
12
  // test/unit/features/import-contract.test.ts).
13
13
 
14
+ export * from './src/ingress'
15
+ // Framework-agnostic integration helpers: run the ingress pipeline as a
16
+ // request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
17
+ // and SSE framing (sseResponse). See docs/INGRESS.md.
18
+ export * from './src/integration'
19
+ // Higher-order loader (HFC) over the curated op set: createLoader / loader
20
+ // dispatch single items, packed batches, and load() coalescing. See README.
21
+ export * from './src/loader'
14
22
  export * from './src/rust-ffi'
15
-
23
+ export type { OpDecision, OpImpl } from './src/selection'
16
24
  // Native-vs-JS selection (OWNED HERE, not by consumers): `opImpl(op)` is the
17
25
  // benchmark-driven recommendation for which operations should use the Rust
18
26
  // addon vs a pure-JS implementation. Consumers read it once at load time and
19
27
  // bind each op to a fixed impl. Source: rust/selection.rs (audited by
20
28
  // `bun run check:selection`). See src/selection.ts.
21
- export { opImpl, isNativeOp, opDecision } from './src/selection'
22
- export type { OpImpl, OpDecision } from './src/selection'
23
-
24
- export { encoder, decoder } from './src/shared/bytes'
29
+ export { isNativeOp, opDecision, opImpl } from './src/selection'
30
+ export type { AdaptiveEstimateOptions } from './src/shared/adaptive'
25
31
  // Bounded EWMA adaptive-estimate utility (drives BufferPool adaptive sizing
26
32
  // and reusable runtime self-optimization decisions).
27
33
  export { AdaptiveEstimate } from './src/shared/adaptive'
28
- export type { AdaptiveEstimateOptions } from './src/shared/adaptive'
29
- // UUIDv7 generation — delegates to Bun.randomUUIDv7, crypto.randomUUID on Node.
30
- export { uuidv7 } from './src/shared/uuid'
34
+ export { decoder, encoder } from './src/shared/bytes'
35
+ export type { Counter, Gauge, Histogram, MetricsRegistry } from './src/shared/metrics'
31
36
  // Zero-dep metrics registry (counters/gauges/histograms + Prometheus render).
32
37
  export { createMetrics, DEFAULT_BUCKETS } from './src/shared/metrics'
33
- export type { MetricsRegistry, Counter, Gauge, Histogram } from './src/shared/metrics'
34
-
38
+ export type { Pair, ParsedHttpRequestPacked } from './src/shared/packed'
35
39
  export {
36
40
  packBatch,
37
41
  packPairs,
38
- readPairsPacked,
39
- readHttpPacked,
40
42
  pairsToObject,
41
- parseQueryString,
42
43
  parseCookieHeader,
43
44
  parseFormBody,
45
+ parseQueryString,
46
+ readHttpPacked,
47
+ readPairsPacked,
44
48
  unpackBitset,
45
49
  unpackByteResults,
46
50
  unpackI64ArrayAsBigInt,
47
51
  unpackU32Array,
48
52
  } from './src/shared/packed'
49
- export type { Pair, ParsedHttpRequestPacked } from './src/shared/packed'
50
-
51
- export * from './src/ingress'
52
-
53
- // Framework-agnostic integration helpers: run the ingress pipeline as a
54
- // request stage (createPipeline), RFC 6455 upgrade (createWebSocketUpgrade),
55
- // and SSE framing (sseResponse). See docs/INGRESS.md.
56
- export * from './src/integration'
57
-
58
- // Higher-order loader (HFC) over the curated op set: createLoader / loader
59
- // dispatch single items, packed batches, and load() coalescing. See README.
60
- export * from './src/loader'
53
+ // UUIDv7 generation — delegates to Bun.randomUUIDv7, crypto.randomUUID on Node.
54
+ export { uuidv7 } from './src/shared/uuid'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -48,11 +48,18 @@
48
48
  "docs",
49
49
  "README.md",
50
50
  "*.node",
51
+ "scripts/postinstall.mjs",
52
+ "rust",
53
+ "Cargo.toml",
54
+ "Cargo.lock",
55
+ "build.rs",
56
+ "rust-toolchain.toml",
51
57
  "!src/bench",
52
58
  "!src/baseline",
53
59
  "!src/data"
54
60
  ],
55
61
  "scripts": {
62
+ "postinstall": "node scripts/postinstall.mjs",
56
63
  "build": "napi build --release --platform",
57
64
  "build:perf": "bash scripts/build-perf.sh",
58
65
  "build:v3": "bash scripts/build-v3.sh",
@@ -104,6 +111,7 @@
104
111
  "bench:startup": "bun run bench/startup.ts",
105
112
  "bench:load": "bun run bench/http/load-phase.ts",
106
113
  "bench:ffi": "bun run bench/ffi/ffi-all.ts",
114
+ "bench:newops": "bun bench/cost/rust-vs-js-new-ops.ts",
107
115
  "bench:ffi:load": "bun run bench/ffi/ffi-load.ts",
108
116
  "bench:ffi:public": "bun run bench/ffi/ffi-public.ts",
109
117
  "bench:ffi:workers": "bun run bench/ffi/ffi-workers.ts",
@@ -0,0 +1,378 @@
1
+ // rust/crypto/aead.rs — authenticated encryption (AES-256-GCM, ChaCha20-Poly1305).
2
+ //
3
+ // Backend-framework feature: symmetric encryption for cookies / session
4
+ // payloads / at-rest secrets. Uses aws-lc-rs's `aead` module (already a
5
+ // dependency for HMAC-SHA256), so no new crate is pulled in. Nonce and plaintext
6
+ // lengths are validated; a 12-byte nonce is required (both algorithms use the
7
+ // same 96-bit nonce length).
8
+ //
9
+ // Pure-Rust core (no napi types) stays unit-testable; only the entry points
10
+ // use napi types.
11
+
12
+ use aws_lc_rs::aead::{self, Aad, LessSafeKey, Nonce, UnboundKey};
13
+ use aws_lc_rs::error::Unspecified;
14
+ use napi::bindgen_prelude::*;
15
+ use napi_derive::napi;
16
+
17
+ const NONCE_LEN: usize = 12;
18
+
19
+ /// Derive a unique per-item nonce for the batch APIs.
20
+ ///
21
+ /// AES-GCM and ChaCha20-Poly1305 are catastrophic under nonce reuse with the
22
+ /// same key. The batch APIs accept ONE caller-provided base nonce; this derives
23
+ /// a distinct nonce per item by XOR-ing the item index into the low 8 bytes.
24
+ ///
25
+ /// SAFETY CONTRACT: callers MUST use a fresh base nonce for every batch call
26
+ /// (never reuse a base nonce with the same key across calls), otherwise item `i`
27
+ /// of two different batches share a nonce and keystream/tag reuse applies.
28
+ #[inline]
29
+ fn batch_nonce(base: &[u8], index: usize) -> [u8; NONCE_LEN] {
30
+ let mut nonce = [0u8; NONCE_LEN];
31
+ nonce.copy_from_slice(base);
32
+ let idx = (index as u64).to_be_bytes();
33
+ for (slot, byte) in nonce[4..].iter_mut().zip(idx.iter()) {
34
+ *slot ^= byte;
35
+ }
36
+ nonce
37
+ }
38
+
39
+ fn resolve_algorithm(name: Option<&str>) -> Result<&'static aead::Algorithm> {
40
+ match name {
41
+ None | Some("aes-256-gcm") => Ok(&aead::AES_256_GCM),
42
+ Some("chacha20-poly1305") => Ok(&aead::CHACHA20_POLY1305),
43
+ Some(other) => Err(Error::from_reason(format!(
44
+ "unsupported aead algorithm: {other} (expected aes-256-gcm | chacha20-poly1305)"
45
+ ))),
46
+ }
47
+ }
48
+
49
+ // ── Pure-Rust core ─────────────────────────────────────────────
50
+
51
+ /// Encrypt `plaintext`, appending the 16-byte auth tag. Returns the ciphertext
52
+ /// (plaintext length + tag).
53
+ pub fn encrypt(
54
+ alg: &'static aead::Algorithm,
55
+ key: &[u8],
56
+ nonce: &[u8],
57
+ plaintext: &[u8],
58
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
59
+ let unbound = UnboundKey::new(alg, key)?;
60
+ let sealing = LessSafeKey::new(unbound);
61
+ encrypt_with_key(&sealing, nonce, plaintext)
62
+ }
63
+
64
+ /// Encrypt with a PRE-COMPILED key (no per-call `UnboundKey`/`LessSafeKey`
65
+ /// derivation — the win the `AeadCipher` instance exploits).
66
+ pub fn encrypt_with_key(
67
+ sealing: &LessSafeKey,
68
+ nonce: &[u8],
69
+ plaintext: &[u8],
70
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
71
+ if nonce.len() != NONCE_LEN {
72
+ return Err(Unspecified);
73
+ }
74
+ let mut nonce_arr = [0u8; NONCE_LEN];
75
+ nonce_arr.copy_from_slice(nonce);
76
+ let nonce = Nonce::assume_unique_for_key(nonce_arr);
77
+ let mut in_out = plaintext.to_vec();
78
+ sealing.seal_in_place_append_tag(nonce, Aad::empty(), &mut in_out)?;
79
+ Ok(in_out)
80
+ }
81
+
82
+ /// Decrypt `ciphertext` (ciphertext + tag), returning the plaintext.
83
+ pub fn decrypt(
84
+ alg: &'static aead::Algorithm,
85
+ key: &[u8],
86
+ nonce: &[u8],
87
+ ciphertext: &[u8],
88
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
89
+ let unbound = UnboundKey::new(alg, key)?;
90
+ let opening = LessSafeKey::new(unbound);
91
+ decrypt_with_key(&opening, nonce, ciphertext)
92
+ }
93
+
94
+ /// Decrypt with a PRE-COMPILED key (no per-call key derivation).
95
+ pub fn decrypt_with_key(
96
+ opening: &LessSafeKey,
97
+ nonce: &[u8],
98
+ ciphertext: &[u8],
99
+ ) -> std::result::Result<Vec<u8>, Unspecified> {
100
+ if nonce.len() != NONCE_LEN {
101
+ return Err(Unspecified);
102
+ }
103
+ let mut nonce_arr = [0u8; NONCE_LEN];
104
+ nonce_arr.copy_from_slice(nonce);
105
+ let nonce = Nonce::assume_unique_for_key(nonce_arr);
106
+ // Single copy: borrow the ciphertext in, then truncate in place to the
107
+ // plaintext length (the tag occupies the trailing bytes) and return it.
108
+ let mut in_out = ciphertext.to_vec();
109
+ let opened = opening.open_in_place(nonce, Aad::empty(), &mut in_out)?;
110
+ let plaintext_len = opened.len();
111
+ in_out.truncate(plaintext_len);
112
+ Ok(in_out)
113
+ }
114
+
115
+ // ── NAPI entry points ──────────────────────────────────────────
116
+
117
+ /// Encrypt `plaintext` → ciphertext+tag. `key` must be 32 bytes; `nonce` must
118
+ /// be 12 bytes. Defaults to AES-256-GCM; pass `"chacha20-poly1305"` to switch.
119
+ #[napi]
120
+ pub fn aead_encrypt(
121
+ key: Uint8Array,
122
+ nonce: Uint8Array,
123
+ plaintext: Uint8Array,
124
+ algorithm: Option<String>,
125
+ ) -> Result<Buffer> {
126
+ let alg = resolve_algorithm(algorithm.as_deref())?;
127
+ encrypt(alg, key.as_ref(), nonce.as_ref(), plaintext.as_ref())
128
+ .map(Buffer::from)
129
+ .map_err(|e| Error::from_reason(format!("aead encrypt failed: {e:?}")))
130
+ }
131
+
132
+ /// Decrypt `ciphertext`+tag → plaintext. Returns `null` when the tag fails to
133
+ /// authenticate (tampered / wrong key) or the inputs are malformed.
134
+ #[napi]
135
+ pub fn aead_decrypt(
136
+ key: Uint8Array,
137
+ nonce: Uint8Array,
138
+ ciphertext: Uint8Array,
139
+ algorithm: Option<String>,
140
+ ) -> Result<Option<Buffer>> {
141
+ let alg = resolve_algorithm(algorithm.as_deref())?;
142
+ match decrypt(alg, key.as_ref(), nonce.as_ref(), ciphertext.as_ref()) {
143
+ Ok(pt) => Ok(Some(Buffer::from(pt))),
144
+ Err(_) => Ok(None),
145
+ }
146
+ }
147
+
148
+ /// Higher-order instance: precompiles the algorithm + key into a `LessSafeKey`
149
+ /// once at construction, so `encrypt`/`decrypt` never re-derive the key on the
150
+ /// per-call path.
151
+ #[napi]
152
+ pub struct AeadCipher {
153
+ key: LessSafeKey,
154
+ }
155
+
156
+ #[napi]
157
+ impl AeadCipher {
158
+ #[napi(constructor)]
159
+ pub fn new(key: Uint8Array, algorithm: Option<String>) -> Result<Self> {
160
+ let alg = resolve_algorithm(algorithm.as_deref())?;
161
+ let unbound = UnboundKey::new(alg, key.as_ref())
162
+ .map_err(|e| Error::from_reason(format!("invalid aead key: {e:?}")))?;
163
+ Ok(Self {
164
+ key: LessSafeKey::new(unbound),
165
+ })
166
+ }
167
+
168
+ /// Encrypt `plaintext` → ciphertext+tag with the precompiled key.
169
+ #[napi]
170
+ pub fn encrypt(&self, nonce: Uint8Array, plaintext: Uint8Array) -> Result<Buffer> {
171
+ encrypt_with_key(&self.key, nonce.as_ref(), plaintext.as_ref())
172
+ .map(Buffer::from)
173
+ .map_err(|e| Error::from_reason(format!("aead encrypt failed: {e:?}")))
174
+ }
175
+
176
+ /// Decrypt `ciphertext`+tag → plaintext, or `null` on auth failure.
177
+ #[napi]
178
+ pub fn decrypt(&self, nonce: Uint8Array, ciphertext: Uint8Array) -> Result<Option<Buffer>> {
179
+ match decrypt_with_key(&self.key, nonce.as_ref(), ciphertext.as_ref()) {
180
+ Ok(pt) => Ok(Some(Buffer::from(pt))),
181
+ Err(_) => Ok(None),
182
+ }
183
+ }
184
+ }
185
+
186
+ /// Parallel encrypt batch: packed `[u32 count]{[u32 len][plaintext]}` in →
187
+ /// packed `[u32 count]{[u32 len][ciphertext+tag]}` out.
188
+ ///
189
+ /// SECURITY: a unique nonce is derived per item from the caller-provided base
190
+ /// `nonce` (item index XOR-ed into the low 8 bytes). The base nonce MUST be
191
+ /// fresh for every batch call — never reuse a base nonce with the same key.
192
+ #[napi]
193
+ pub fn aead_encrypt_batch_packed(
194
+ data: Uint8Array,
195
+ key: Uint8Array,
196
+ nonce: Uint8Array,
197
+ algorithm: Option<String>,
198
+ ) -> Result<Buffer> {
199
+ let alg = resolve_algorithm(algorithm.as_deref())?;
200
+
201
+ let enc_for = |plaintext: &[u8], index: usize| -> Vec<u8> {
202
+ let n = batch_nonce(nonce.as_ref(), index);
203
+ encrypt(alg, key.as_ref(), &n, plaintext).unwrap_or_default()
204
+ };
205
+
206
+ crate::util::run_packed_batch_idx(data.as_ref(), enc_for).map(Buffer::from)
207
+ }
208
+
209
+ /// Parallel decrypt batch: packed ciphertexts in → packed plaintexts out.
210
+ /// Items whose tag fails to authenticate produce an empty (zero-length) entry.
211
+ ///
212
+ /// SECURITY: the SAME per-item nonce derivation as
213
+ /// {@link aead_encrypt_batch_packed} must be used — pass the same base `nonce`
214
+ /// and batch ordering to decrypt what was encrypted.
215
+ #[napi]
216
+ pub fn aead_decrypt_batch_packed(
217
+ data: Uint8Array,
218
+ key: Uint8Array,
219
+ nonce: Uint8Array,
220
+ algorithm: Option<String>,
221
+ ) -> Result<Buffer> {
222
+ let alg = resolve_algorithm(algorithm.as_deref())?;
223
+
224
+ let dec_for = |ciphertext: &[u8], index: usize| -> Vec<u8> {
225
+ let n = batch_nonce(nonce.as_ref(), index);
226
+ decrypt(alg, key.as_ref(), &n, ciphertext).unwrap_or_default()
227
+ };
228
+
229
+ crate::util::run_packed_batch_idx(data.as_ref(), dec_for).map(Buffer::from)
230
+ }
231
+
232
+ #[cfg(test)]
233
+ mod tests {
234
+ use super::*;
235
+
236
+ fn key_bytes() -> [u8; 32] {
237
+ [7u8; 32]
238
+ }
239
+
240
+ fn nonce_bytes() -> [u8; 12] {
241
+ [3u8; 12]
242
+ }
243
+
244
+ #[test]
245
+ fn aes256gcm_roundtrip() {
246
+ let pt = b"secret session payload";
247
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
248
+ assert_eq!(ct.len(), pt.len() + 16);
249
+ let dec = decrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), &ct).unwrap();
250
+ assert_eq!(dec, pt);
251
+ }
252
+
253
+ #[test]
254
+ fn aead_cipher_instance_roundtrips_and_rejects() {
255
+ // Precompiled-key instance: encrypt/decrypt with no per-call key
256
+ // derivation. Must match the scalar core exactly.
257
+ let cipher = AeadCipher::new(Uint8Array::new(key_bytes().to_vec()), None).unwrap();
258
+ let pt = b"session cookie value";
259
+
260
+ let ct = cipher
261
+ .encrypt(
262
+ Uint8Array::new(nonce_bytes().to_vec()),
263
+ Uint8Array::new(pt.to_vec()),
264
+ )
265
+ .unwrap();
266
+ assert_eq!(ct.len(), pt.len() + 16);
267
+
268
+ let ct_bytes = ct.to_vec();
269
+ let dec = cipher
270
+ .decrypt(
271
+ Uint8Array::new(nonce_bytes().to_vec()),
272
+ Uint8Array::new(ct_bytes.clone()),
273
+ )
274
+ .unwrap();
275
+ assert_eq!(&dec.unwrap()[..], pt);
276
+
277
+ // Wrong-key instance fails to authenticate.
278
+ let other = AeadCipher::new(Uint8Array::new([9u8; 32].to_vec()), None).unwrap();
279
+ assert!(other
280
+ .decrypt(
281
+ Uint8Array::new(nonce_bytes().to_vec()),
282
+ Uint8Array::new(ct_bytes)
283
+ )
284
+ .unwrap()
285
+ .is_none());
286
+
287
+ // Malformed nonce length → error.
288
+ assert!(cipher
289
+ .encrypt(Uint8Array::new(vec![0u8; 8]), Uint8Array::new(pt.to_vec()))
290
+ .is_err());
291
+
292
+ // Unsupported algorithm → construction error.
293
+ assert!(AeadCipher::new(
294
+ Uint8Array::new(key_bytes().to_vec()),
295
+ Some("aes-128-gcm".to_string())
296
+ )
297
+ .is_err());
298
+ }
299
+
300
+ #[test]
301
+ fn batch_nonce_unique_per_item() {
302
+ // Regression: the batch APIs must NEVER reuse a nonce across items.
303
+ let base = nonce_bytes();
304
+ let n0 = batch_nonce(&base, 0);
305
+ let n1 = batch_nonce(&base, 1);
306
+ let n2 = batch_nonce(&base, 2);
307
+ assert_ne!(n0, n1, "nonce for item 0 and 1 must differ");
308
+ assert_ne!(n1, n2, "nonce for item 1 and 2 must differ");
309
+ assert_ne!(n0, n2);
310
+ // Uniqueness for a full large batch too.
311
+ let mut seen = std::collections::HashSet::new();
312
+ for i in 0..1024 {
313
+ assert!(
314
+ seen.insert(batch_nonce(&base, i)),
315
+ "nonce reused at item {i}"
316
+ );
317
+ }
318
+ }
319
+
320
+ #[test]
321
+ fn batch_nonce_derived_encrypt_roundtrip() {
322
+ // Each item encrypted with its derived nonce decrypts correctly, and a
323
+ // mismatched nonce fails to authenticate (catches nonce-reuse misuse).
324
+ let alg = &aead::AES_256_GCM;
325
+ let key = key_bytes();
326
+ let base = nonce_bytes();
327
+
328
+ let p0 = b"first payload";
329
+ let p1 = b"second payload";
330
+
331
+ let c0 = encrypt(alg, &key, &batch_nonce(&base, 0), p0).unwrap();
332
+ let c1 = encrypt(alg, &key, &batch_nonce(&base, 1), p1).unwrap();
333
+
334
+ assert_eq!(decrypt(alg, &key, &batch_nonce(&base, 0), &c0).unwrap(), p0);
335
+ assert_eq!(decrypt(alg, &key, &batch_nonce(&base, 1), &c1).unwrap(), p1);
336
+ // Cross-nonce decryption must fail authentication.
337
+ assert!(decrypt(alg, &key, &batch_nonce(&base, 1), &c0).is_err());
338
+ assert!(decrypt(alg, &key, &batch_nonce(&base, 0), &c1).is_err());
339
+ }
340
+
341
+ #[test]
342
+ fn chacha20_roundtrip() {
343
+ let pt = b"another payload";
344
+ let ct = encrypt(&aead::CHACHA20_POLY1305, &key_bytes(), &nonce_bytes(), pt).unwrap();
345
+ let dec = decrypt(&aead::CHACHA20_POLY1305, &key_bytes(), &nonce_bytes(), &ct).unwrap();
346
+ assert_eq!(dec, pt);
347
+ }
348
+
349
+ #[test]
350
+ fn tampered_ciphertext_fails_auth() {
351
+ let pt = b"integrity matters";
352
+ let mut ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
353
+ ct[0] ^= 0xff;
354
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), &ct).is_err());
355
+ }
356
+
357
+ #[test]
358
+ fn wrong_key_fails_auth() {
359
+ let pt = b"integrity matters";
360
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
361
+ let wrong_key = [9u8; 32];
362
+ assert!(decrypt(&aead::AES_256_GCM, &wrong_key, &nonce_bytes(), &ct).is_err());
363
+ }
364
+
365
+ #[test]
366
+ fn wrong_nonce_fails_auth() {
367
+ let pt = b"integrity matters";
368
+ let ct = encrypt(&aead::AES_256_GCM, &key_bytes(), &nonce_bytes(), pt).unwrap();
369
+ let wrong_nonce = [4u8; 12];
370
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &wrong_nonce, &ct).is_err());
371
+ }
372
+
373
+ #[test]
374
+ fn rejects_bad_nonce_length() {
375
+ assert!(encrypt(&aead::AES_256_GCM, &key_bytes(), &[0u8; 8], b"x").is_err());
376
+ assert!(decrypt(&aead::AES_256_GCM, &key_bytes(), &[0u8; 8], b"x").is_err());
377
+ }
378
+ }