castrum 0.9.6 → 0.9.8

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 (88) hide show
  1. package/Cargo.lock +4 -3
  2. package/Cargo.toml +1 -1
  3. package/castrum.darwin-arm64.node +0 -0
  4. package/castrum.darwin-x64.node +0 -0
  5. package/castrum.linux-arm64-gnu.node +0 -0
  6. package/castrum.linux-arm64-musl.node +0 -0
  7. package/castrum.linux-x64-gnu.node +0 -0
  8. package/castrum.linux-x64-musl.node +0 -0
  9. package/castrum.linux-x64-v3-gnu.node +0 -0
  10. package/castrum.win32-x64-msvc.node +0 -0
  11. package/dist/index.d.ts +501 -5
  12. package/dist/index.js +1658 -1314
  13. package/docs/ARCHITECTURE.md +1 -1
  14. package/docs/BENCHMARKS.md +13 -0
  15. package/docs/CASE_STUDY.md +2 -2
  16. package/docs/ENVIRONMENT.md +1 -1
  17. package/docs/FFI_BUN_GUIDE.md +13 -1
  18. package/docs/HOW_TO_ADD_AN_OP.md +24 -0
  19. package/docs/INGRESS.md +4 -4
  20. package/docs/REPO_MAP.md +8 -6
  21. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  22. package/index.ts +1 -0
  23. package/package.json +3 -2
  24. package/rust/crypto/aead.rs +44 -0
  25. package/rust/crypto/argon2.rs +32 -2
  26. package/rust/crypto/jwt/tests.rs +19 -0
  27. package/rust/crypto/jwt/token.rs +43 -28
  28. package/rust/crypto/session.rs +170 -7
  29. package/rust/ffi/crypto.rs +182 -61
  30. package/rust/ffi/hashing.rs +48 -33
  31. package/rust/ffi/http.rs +62 -36
  32. package/rust/ffi/metrics.rs +6 -2
  33. package/rust/ffi/payload.rs +47 -27
  34. package/rust/ffi/task.rs +51 -32
  35. package/rust/ffi/tests.rs +197 -0
  36. package/rust/ffi/util.rs +40 -0
  37. package/rust/http/headers.rs +13 -0
  38. package/rust/http/multipart.rs +139 -0
  39. package/rust/ingress/native_route.rs +21 -3
  40. package/rust/task/completion.rs +7 -0
  41. package/rust/task/mod.rs +1 -1
  42. package/rust/task/tests.rs +30 -0
  43. package/src/ingress/decode/baked-result.ts +13 -5
  44. package/src/ingress/decode/fast-result.ts +1 -1
  45. package/src/ingress/decode/result-base.ts +16 -2
  46. package/src/ingress/handlers.ts +103 -31
  47. package/src/ingress/headers/memoized-headers.ts +40 -0
  48. package/src/ingress/native-route.ts +25 -6
  49. package/src/ingress/options.ts +26 -2
  50. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  51. package/src/ingress/response/baked-response.ts +118 -28
  52. package/src/ingress/router.ts +14 -2
  53. package/src/ingress/routes/common.ts +26 -10
  54. package/src/ingress/routes/echo.ts +50 -7
  55. package/src/ingress/routes/json-write.ts +21 -8
  56. package/src/ingress/routes/responder.ts +4 -4
  57. package/src/ingress/server-node.ts +33 -14
  58. package/src/ingress/server.ts +3 -4
  59. package/src/ingress/shared.ts +1 -1
  60. package/src/ingress/types.ts +15 -0
  61. package/src/loader/ops.ts +1 -1
  62. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  63. package/src/native/ffi/build/codecs.ts +0 -296
  64. package/src/native/ffi/build/instances-selftest.ts +264 -0
  65. package/src/native/ffi/build/instances.ts +39 -241
  66. package/src/native/ffi/build/metrics.ts +0 -7
  67. package/src/native/ffi/build/parse-selftest.ts +238 -0
  68. package/src/native/ffi/build/parse.ts +11 -234
  69. package/src/native/ffi/build.ts +5 -1
  70. package/src/native/ffi/selftest.ts +3 -3
  71. package/src/native/ffi/symbols.ts +460 -0
  72. package/src/native/ffi/types.ts +17 -0
  73. package/src/native/ffi.ts +2 -422
  74. package/src/native/ingress-binding.ts +39 -0
  75. package/src/runtime/codec.ts +24 -0
  76. package/src/runtime/types.ts +11 -0
  77. package/src/rust-ffi/batch/build.ts +18 -8
  78. package/src/rust-ffi/batch/types.ts +15 -0
  79. package/src/rust-ffi/options.ts +19 -0
  80. package/src/rust-ffi/scalar/crypto.ts +8 -0
  81. package/src/rust-ffi/scalar/factories.ts +47 -20
  82. package/src/rust-ffi/scalar/http.ts +60 -10
  83. package/src/rust-ffi/scalar/interface.ts +4 -1
  84. package/src/rust-ffi/scalar/payload.ts +27 -1
  85. package/src/shared/buffer-pool.ts +44 -9
  86. package/src/shared/codec.ts +26 -0
  87. package/src/shared/packed/parsers.ts +64 -7
  88. package/src/task/runtime.ts +29 -28
@@ -0,0 +1,264 @@
1
+ // src/native/ffi/build/instances-selftest.ts — bind-time self-test for the
2
+ // opaque-handle (compiled-instance) BunFFI surface.
3
+ //
4
+ // Extracted from instances.ts so the method builders and the value probes stay
5
+ // separate modules; `selftest.ts` ANDs this with the other per-domain probes.
6
+
7
+ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
8
+ import type { BunFFI } from '../types'
9
+
10
+ /**
11
+ * Bind-time self-test for the opaque-handle surface (the methods built in
12
+ * `buildInstances`). `false` disables the ffi layer and forces the napi
13
+ * fallback.
14
+ */
15
+ export function selfTestInstances(b: BunFFI): boolean {
16
+ const enc = { encode: encodeUtf8 }
17
+ const dec = { decode: decodeUtf8 }
18
+
19
+ // Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
20
+ // `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
21
+ // Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
22
+ // field against output.rs. Slot order mirrors the struct field order.
23
+ const layoutBuf = new Uint8Array(38 * 4)
24
+ b.ingressLayout(layoutBuf)
25
+ const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
26
+ if (
27
+ layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
28
+ layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
29
+ layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
30
+ layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
31
+ layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
32
+ layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
33
+ ) {
34
+ return false
35
+ }
36
+
37
+ // Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
38
+ // 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
39
+ // count/types/return) at bind time — a signature drift would surface here
40
+ // instead of crashing under load. Real frame→output parity is covered by
41
+ // ffi.test.ts against a live napi instance.
42
+ try {
43
+ b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
44
+ return false // a null handle must throw, not return
45
+ } catch {
46
+ // expected: null inner handle → 0 → throw
47
+ }
48
+
49
+ // Ingress raw-components C-ABI: with a null (0) inner handle the Rust side
50
+ // returns 0 immediately and the wrapper throws — exercises the 12-arg ABI
51
+ // (incl. the two `cstring` slots for url/ip) at bind time. Real frame→output
52
+ // parity is covered by ffi.test.ts against a live napi instance.
53
+ try {
54
+ b.ingressHandleComponents(
55
+ 0,
56
+ 0, // GET
57
+ '/',
58
+ '',
59
+ enc.encode('rid'),
60
+ new Uint8Array(2), // empty packed headers [u16 0]
61
+ null,
62
+ new Uint8Array(64),
63
+ )
64
+ return false // a null handle must throw, not return
65
+ } catch {
66
+ // expected: null inner handle → 0 → throw
67
+ }
68
+
69
+ // Per-route native stack (castrum_route_*): compile a parseQuery-only
70
+ // descriptor, run one frame, assert the packed verdict. Exercises all three
71
+ // symbols' ABI at bind time (handle return, run needed-size, destroy).
72
+ const routeDesc = new Uint8Array(33)
73
+ const rd = new DataView(routeDesc.buffer)
74
+ rd.setUint32(0, 0x524f5554, true) // ROUTE_DESC_MAGIC "ROUT"
75
+ rd.setUint32(4, 3, true) // ROUTE_DESC_VERSION
76
+ rd.setUint32(8, 2 * 1024 * 1024, true) // maxBodyBytes
77
+ rd.setUint32(12, 8192, true) // maxQueryBytes
78
+ rd.setUint32(16, 8192, true) // maxCookieBytes
79
+ rd.setUint32(20, 0, true) // maxPairs
80
+ rd.setUint32(24, 1, true) // stageCount
81
+ routeDesc[28] = 0 // parseQuery
82
+ rd.setUint32(29, 0, true) // schemaCount
83
+ const routeHandle = b.routeCompile(routeDesc)
84
+ if (routeHandle === 0) {
85
+ return false
86
+ }
87
+ const routeFrame = new Uint8Array(15)
88
+ const rf = new DataView(routeFrame.buffer)
89
+ rf.setUint32(0, 0, true) // flags (no body)
90
+ rf.setUint32(4, 3, true) // qLen
91
+ routeFrame.set(enc.encode('a=1'), 8)
92
+ rf.setUint32(11, 0, true) // cLen
93
+ const routeOut = new Uint8Array(64)
94
+ const routeW = b.routeRun(routeHandle, routeFrame, routeOut)
95
+ if (routeW <= 8) {
96
+ return false // header + a query pair section must exceed 8 bytes
97
+ }
98
+ const rv = new DataView(routeOut.buffer)
99
+ const rFlags = rv.getUint32(0, true)
100
+ if ((rFlags & 0b1) === 0 || (rFlags & 0b100) === 0) {
101
+ return false // OK + QUERY_VALID bits must be set
102
+ }
103
+ b.routeDestroy(routeHandle)
104
+
105
+ // ConditionalRequest opaque-handle: a null (0) handle must return false (the
106
+ // C side never dereferences freed state) — exercises the symbol's ABI. Real
107
+ // verdict parity is covered by test/unit/features/etag.test.ts.
108
+ if (b.conditionalIsNotModified(0, null, null) !== false) {
109
+ return false
110
+ }
111
+
112
+ // Phase 6 opaque-handle instances: null (0) handles must fail SAFELY (false /
113
+ // null / throw — never dereference freed state). Real verdict parity is
114
+ // covered by the per-instance JS tests.
115
+ if (b.mediaTypeMatcherMatches(0, enc.encode('x')) !== false) return false
116
+ if (b.acceptNegotiatorNegotiate(0, enc.encode('gzip')) !== null) return false
117
+ if (b.acceptNegotiatorNegotiateServer(0, enc.encode('gzip')) !== null) return false
118
+ if (b.schemaValidatorValidate(0, enc.encode('{}')) !== false) return false
119
+ if (b.jwtSignerVerify(0, enc.encode('a.b.c'), 0) !== null) return false
120
+ let phase6Threw = false
121
+ try {
122
+ b.jwtSignerSign(0, enc.encode('{}'), 0)
123
+ } catch {
124
+ phase6Threw = true
125
+ }
126
+ if (!phase6Threw) return false
127
+ phase6Threw = false
128
+ try {
129
+ b.templateRender(0, enc.encode('{}'))
130
+ } catch {
131
+ phase6Threw = true
132
+ }
133
+ if (!phase6Threw) return false
134
+ phase6Threw = false
135
+ try {
136
+ b.rateLimiterCheck(0, 'k', 0)
137
+ } catch {
138
+ phase6Threw = true
139
+ }
140
+ if (!phase6Threw) return false
141
+
142
+ // JWT sign pooled Into: same token as the cstring path.
143
+ const jwtClaims = enc.encode('{"sub":"user-1"}')
144
+ const jwtSecret2 = enc.encode('my-secret')
145
+ const jwtOut = new Uint8Array(512)
146
+ const jwtW = b.jwtSignBytesInto(jwtClaims, jwtSecret2, 0, 0, jwtOut)
147
+ if (jwtW === 0 || dec.decode(jwtOut.subarray(0, jwtW)).split('.').length !== 3) {
148
+ return false
149
+ }
150
+
151
+ // JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
152
+ // signature with the FFI HMAC to prove the binding is real.
153
+ const jwtSecret = enc.encode('my-secret')
154
+ const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
155
+ const jwtStr = jwt
156
+ const segs = jwtStr.split('.')
157
+ if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
158
+ return false
159
+ }
160
+ const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
161
+ const sigHex = b.hmacSha256(jwtSecret, signingInput)
162
+ const sigBytes = b.base64Decode(enc.encode(segs[2] ?? ''), true, false)
163
+ if (sigBytes === null || b.hexEncode(sigBytes) !== sigHex) {
164
+ return false
165
+ }
166
+
167
+ // jwtVerify: sign (ttl=0 → no iat/exp) then verify → claims JSON; tampered → null.
168
+ const vjwtSecret = enc.encode('verify-secret')
169
+ const vjwt = b.jwtSignBytes(enc.encode('{"sub":"u-1"}'), vjwtSecret, 0, 0)
170
+ const vjwtClaims = b.jwtVerify(enc.encode(vjwt), vjwtSecret, 0)
171
+ if (vjwtClaims === null || !vjwtClaims.includes('"sub":"u-1"')) {
172
+ return false
173
+ }
174
+ if (b.jwtVerify(enc.encode('tampered.token.value'), vjwtSecret, 0) !== null) {
175
+ return false
176
+ }
177
+
178
+ // Ed25519 round-trip: generate → sign → verify (and a tampered signature
179
+ // must be rejected). The generate wrapper also parses the packed blob, so
180
+ // this pins the `[u32 privLen][priv][u32 pubLen][pub]` layout.
181
+ const edKp = b.ed25519GenerateKeypair()
182
+ const edMsg = enc.encode('ed25519 bind-time self-test')
183
+ const edSig = b.ed25519Sign(edMsg, edKp.privateKey)
184
+ if (edSig.byteLength !== 64 || !b.ed25519Verify(edMsg, edSig, edKp.publicKey)) {
185
+ return false
186
+ }
187
+ const edTampered = edSig.slice()
188
+ edTampered[0] = (edTampered[0] ?? 0) ^ 0xff
189
+ if (b.ed25519Verify(edMsg, edTampered, edKp.publicKey)) {
190
+ return false
191
+ }
192
+ // EdDSA JWT round-trip on the SAME keypair (ttl=0 → no iat/exp, so verify
193
+ // succeeds at any `now`).
194
+ const edToken = b.jwtEdDSASign(enc.encode('{"sub":"ffi-self-test"}'), edKp.privateKey, 0, 0)
195
+ if (edToken === null || edToken.split('.').length !== 3) {
196
+ return false
197
+ }
198
+ const edClaims = b.jwtEdDSAVerify(enc.encode(edToken), edKp.publicKey, 0)
199
+ if (edClaims === null || !edClaims.includes('"sub":"ffi-self-test"')) {
200
+ return false
201
+ }
202
+ if (b.jwtEdDSAVerify(enc.encode('tampered.token.value'), edKp.publicKey, 0) !== null) {
203
+ return false
204
+ }
205
+
206
+ // UrlBuilder opaque-handle: a null (0) handle must throw (the C side never
207
+ // dereferences freed state) — exercises the symbol's ABI. Real resolve
208
+ // parity is covered by test/unit/features/url-join.test.ts.
209
+ let urlBuilderThrew = false
210
+ try {
211
+ b.urlBuilderResolve(0, enc.encode('g'))
212
+ } catch {
213
+ urlBuilderThrew = true
214
+ }
215
+ if (!urlBuilderThrew) {
216
+ return false
217
+ }
218
+ // Wire-validate: null inner (0) → false (ABI exercise); real parity lives
219
+ // in ffi.test.ts with a live compiled instance.
220
+ if (b.queryValidate(0, 'route=/a') !== false) return false
221
+ if (b.cookieValidate(0, 'route=/a') !== false) return false
222
+
223
+ // Session seal/open round trip through the C ABI.
224
+ {
225
+ const tok = b.sessionSeal('sess-9', '{"n":1}', 1234567, 'sekrit')
226
+ if (tok === null || !tok.includes('.') || !tok.startsWith('{"id":"sess-9"')) return false
227
+ const out = new Uint8Array(256)
228
+ const w = b.sessionOpen(tok, 'sekrit', out)
229
+ if (w <= 13 || out[0] !== 1) return false
230
+ // bad signature → 0
231
+ if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
232
+
233
+ // Byte-arg siblings. Both forms share ONE core, so the sealed token must be
234
+ // byte-identical — the strongest available parity check between the two.
235
+ const enc = new TextEncoder()
236
+ const tokB = b.sessionSealBytes(
237
+ enc.encode('sess-9'),
238
+ enc.encode('{"n":1}'),
239
+ 1234567,
240
+ enc.encode('sekrit'),
241
+ )
242
+ if (tokB === null || tokB !== tok) return false
243
+ const outB = new Uint8Array(256)
244
+ const wB = b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB)
245
+ if (wB !== w || outB[0] !== 1) return false
246
+ // Bad signature → 0; a too-small buffer reports the EXACT size, not 0.
247
+ if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('wrong'), outB) !== 0) return false
248
+ if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB.subarray(0, 4)) !== wB)
249
+ return false
250
+ }
251
+
252
+ // rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
253
+ let rlKeyThrew = false
254
+ try {
255
+ b.rateLimiterCheckKey(0, 12345, 0)
256
+ } catch {
257
+ rlKeyThrew = true
258
+ }
259
+ if (!rlKeyThrew) {
260
+ return false
261
+ }
262
+
263
+ return true
264
+ }
@@ -6,7 +6,7 @@
6
6
  // builder) or drives a compiled route / the full ingress pipeline. Receives the
7
7
  // raw dlopen'd symbols and the per-bind context from `build()`.
8
8
 
9
- import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
9
+ import { decodeUtf8 } from '../../../shared/codec'
10
10
  import { EMPTY_VIEW } from '../constants'
11
11
  import type { BunFFI, Raw2, Raw3, Raw4, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
12
12
  import type { BuildCtx } from './util'
@@ -41,6 +41,8 @@ export function buildInstances(
41
41
  const cookieValidateSym = sym.castrum_cookie_validate as (...a: unknown[]) => number | bigint
42
42
  const sessionSealSym = sym.castrum_session_seal as (...a: unknown[]) => string | null
43
43
  const sessionOpenSym = sym.castrum_session_open as Raw4
44
+ const sessionSealBytesSym = sym.castrum_session_seal_bytes as RawCStr
45
+ const sessionOpenBytesSym = sym.castrum_session_open_bytes as Raw6
44
46
  const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
45
47
  const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
46
48
  const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
@@ -81,8 +83,12 @@ export function buildInstances(
81
83
  // Server-preference tie-breaking (RFC 7231 server semantics). The C ABI
82
84
  // takes `header` as a `cstring` ARG, so decode the bytes to a JS string
83
85
  // (the engine transcodes it to the call-scoped NUL-terminated buffer).
86
+ // A raw NUL is not a valid header byte and would TRUNCATE the arg, so the
87
+ // negotiator would evaluate a prefix — fail safe to identity instead.
84
88
  // `null` = identity (napi Option parity).
85
- return acceptNegotiatorNegotiateServerRaw(inner, decodeUtf8(header))
89
+ const text = decodeUtf8(header)
90
+ if (hasNul(text)) return null
91
+ return acceptNegotiatorNegotiateServerRaw(inner, text)
86
92
  },
87
93
  jwtSignerSign(inner, claimsJson, nowSeconds) {
88
94
  // Precompiled key + ttl → compact token. 0 = invalid claims JSON (real
@@ -164,10 +170,40 @@ export function buildInstances(
164
170
  const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)))
165
171
  return w
166
172
  },
173
+ sessionSealBytes(id, dataJson, expSecs, secret) {
174
+ // Byte-arg form: exact lengths, no engine transcode, no NUL truncation.
175
+ // The parity with `sessionSeal` is asserted in the bind-time self-test
176
+ // below (same core ⇒ byte-identical token for identical inputs).
177
+ return sessionSealBytesSym(
178
+ id,
179
+ lenOrView(id),
180
+ dataJson,
181
+ lenOrView(dataJson),
182
+ BigInt(expSecs),
183
+ secret,
184
+ lenOrView(secret),
185
+ )
186
+ },
187
+ sessionOpenBytes(token, secret, output) {
188
+ // Needed-size convention; 0 = bad signature / malformed.
189
+ return Number(
190
+ sessionOpenBytesSym(
191
+ token,
192
+ lenOrView(token),
193
+ secret,
194
+ lenOrView(secret),
195
+ output,
196
+ lenOrView(output),
197
+ ),
198
+ )
199
+ },
167
200
  rateLimiterCheck(inner, key, nowMs) {
168
201
  // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
169
202
  // Reused scratch + cached DataView (no per-call allocs). `key` is a
170
- // `cstring` ARG (the engine transcodes the JS string in-engine).
203
+ // `cstring` ARG (the engine transcodes the JS string in-engine). A NUL
204
+ // would TRUNCATE the key so distinct keys collapse into ONE budget — deny
205
+ // fail-closed instead of silently aliasing them.
206
+ if (hasNul(key)) return { allowed: false, remaining: 0, resetMs: 0 }
171
207
  const out = rateScratch
172
208
  const w = Number(
173
209
  rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
@@ -400,241 +436,3 @@ export function buildInstances(
400
436
  },
401
437
  }
402
438
  }
403
-
404
- /**
405
- * Bind-time self-test for the opaque-handle surface (the methods built in
406
- * `buildInstances`). `false` disables the ffi layer and forces the napi
407
- * fallback.
408
- */
409
- export function selfTestInstances(b: BunFFI): boolean {
410
- const enc = { encode: encodeUtf8 }
411
- const dec = { decode: decodeUtf8 }
412
-
413
- // Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
414
- // `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
415
- // Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
416
- // field against output.rs. Slot order mirrors the struct field order.
417
- const layoutBuf = new Uint8Array(38 * 4)
418
- b.ingressLayout(layoutBuf)
419
- const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
420
- if (
421
- layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
422
- layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
423
- layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
424
- layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
425
- layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
426
- layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
427
- ) {
428
- return false
429
- }
430
-
431
- // Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
432
- // 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
433
- // count/types/return) at bind time — a signature drift would surface here
434
- // instead of crashing under load. Real frame→output parity is covered by
435
- // ffi.test.ts against a live napi instance.
436
- try {
437
- b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
438
- return false // a null handle must throw, not return
439
- } catch {
440
- // expected: null inner handle → 0 → throw
441
- }
442
-
443
- // Ingress raw-components C-ABI: with a null (0) inner handle the Rust side
444
- // returns 0 immediately and the wrapper throws — exercises the 12-arg ABI
445
- // (incl. the two `cstring` slots for url/ip) at bind time. Real frame→output
446
- // parity is covered by ffi.test.ts against a live napi instance.
447
- try {
448
- b.ingressHandleComponents(
449
- 0,
450
- 0, // GET
451
- '/',
452
- '',
453
- enc.encode('rid'),
454
- new Uint8Array(2), // empty packed headers [u16 0]
455
- null,
456
- new Uint8Array(64),
457
- )
458
- return false // a null handle must throw, not return
459
- } catch {
460
- // expected: null inner handle → 0 → throw
461
- }
462
-
463
- // Per-route native stack (castrum_route_*): compile a parseQuery-only
464
- // descriptor, run one frame, assert the packed verdict. Exercises all three
465
- // symbols' ABI at bind time (handle return, run needed-size, destroy).
466
- const routeDesc = new Uint8Array(33)
467
- const rd = new DataView(routeDesc.buffer)
468
- rd.setUint32(0, 0x524f5554, true) // ROUTE_DESC_MAGIC "ROUT"
469
- rd.setUint32(4, 3, true) // ROUTE_DESC_VERSION
470
- rd.setUint32(8, 2 * 1024 * 1024, true) // maxBodyBytes
471
- rd.setUint32(12, 8192, true) // maxQueryBytes
472
- rd.setUint32(16, 8192, true) // maxCookieBytes
473
- rd.setUint32(20, 0, true) // maxPairs
474
- rd.setUint32(24, 1, true) // stageCount
475
- routeDesc[28] = 0 // parseQuery
476
- rd.setUint32(29, 0, true) // schemaCount
477
- const routeHandle = b.routeCompile(routeDesc)
478
- if (routeHandle === 0) {
479
- return false
480
- }
481
- const routeFrame = new Uint8Array(15)
482
- const rf = new DataView(routeFrame.buffer)
483
- rf.setUint32(0, 0, true) // flags (no body)
484
- rf.setUint32(4, 3, true) // qLen
485
- routeFrame.set(enc.encode('a=1'), 8)
486
- rf.setUint32(11, 0, true) // cLen
487
- const routeOut = new Uint8Array(64)
488
- const routeW = b.routeRun(routeHandle, routeFrame, routeOut)
489
- if (routeW <= 8) {
490
- return false // header + a query pair section must exceed 8 bytes
491
- }
492
- const rv = new DataView(routeOut.buffer)
493
- const rFlags = rv.getUint32(0, true)
494
- if ((rFlags & 0b1) === 0 || (rFlags & 0b100) === 0) {
495
- return false // OK + QUERY_VALID bits must be set
496
- }
497
- b.routeDestroy(routeHandle)
498
-
499
- // ConditionalRequest opaque-handle: a null (0) handle must return false (the
500
- // C side never dereferences freed state) — exercises the symbol's ABI. Real
501
- // verdict parity is covered by test/unit/features/etag.test.ts.
502
- if (b.conditionalIsNotModified(0, null, null) !== false) {
503
- return false
504
- }
505
-
506
- // Phase 6 opaque-handle instances: null (0) handles must fail SAFELY (false /
507
- // null / throw — never dereference freed state). Real verdict parity is
508
- // covered by the per-instance JS tests.
509
- if (b.mediaTypeMatcherMatches(0, enc.encode('x')) !== false) return false
510
- if (b.acceptNegotiatorNegotiate(0, enc.encode('gzip')) !== null) return false
511
- if (b.acceptNegotiatorNegotiateServer(0, enc.encode('gzip')) !== null) return false
512
- if (b.schemaValidatorValidate(0, enc.encode('{}')) !== false) return false
513
- if (b.jwtSignerVerify(0, enc.encode('a.b.c'), 0) !== null) return false
514
- let phase6Threw = false
515
- try {
516
- b.jwtSignerSign(0, enc.encode('{}'), 0)
517
- } catch {
518
- phase6Threw = true
519
- }
520
- if (!phase6Threw) return false
521
- phase6Threw = false
522
- try {
523
- b.templateRender(0, enc.encode('{}'))
524
- } catch {
525
- phase6Threw = true
526
- }
527
- if (!phase6Threw) return false
528
- phase6Threw = false
529
- try {
530
- b.rateLimiterCheck(0, 'k', 0)
531
- } catch {
532
- phase6Threw = true
533
- }
534
- if (!phase6Threw) return false
535
-
536
- // JWT sign pooled Into: same token as the cstring path.
537
- const jwtClaims = enc.encode('{"sub":"user-1"}')
538
- const jwtSecret2 = enc.encode('my-secret')
539
- const jwtOut = new Uint8Array(512)
540
- const jwtW = b.jwtSignBytesInto(jwtClaims, jwtSecret2, 0, 0, jwtOut)
541
- if (jwtW === 0 || dec.decode(jwtOut.subarray(0, jwtW)).split('.').length !== 3) {
542
- return false
543
- }
544
-
545
- // JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
546
- // signature with the FFI HMAC to prove the binding is real.
547
- const jwtSecret = enc.encode('my-secret')
548
- const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
549
- const jwtStr = jwt
550
- const segs = jwtStr.split('.')
551
- if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
552
- return false
553
- }
554
- const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
555
- const sigHex = b.hmacSha256(jwtSecret, signingInput)
556
- const sigBytes = b.base64Decode(enc.encode(segs[2] ?? ''), true, false)
557
- if (sigBytes === null || b.hexEncode(sigBytes) !== sigHex) {
558
- return false
559
- }
560
-
561
- // jwtVerify: sign (ttl=0 → no iat/exp) then verify → claims JSON; tampered → null.
562
- const vjwtSecret = enc.encode('verify-secret')
563
- const vjwt = b.jwtSignBytes(enc.encode('{"sub":"u-1"}'), vjwtSecret, 0, 0)
564
- const vjwtClaims = b.jwtVerify(enc.encode(vjwt), vjwtSecret, 0)
565
- if (vjwtClaims === null || !vjwtClaims.includes('"sub":"u-1"')) {
566
- return false
567
- }
568
- if (b.jwtVerify(enc.encode('tampered.token.value'), vjwtSecret, 0) !== null) {
569
- return false
570
- }
571
-
572
- // Ed25519 round-trip: generate → sign → verify (and a tampered signature
573
- // must be rejected). The generate wrapper also parses the packed blob, so
574
- // this pins the `[u32 privLen][priv][u32 pubLen][pub]` layout.
575
- const edKp = b.ed25519GenerateKeypair()
576
- const edMsg = enc.encode('ed25519 bind-time self-test')
577
- const edSig = b.ed25519Sign(edMsg, edKp.privateKey)
578
- if (edSig.byteLength !== 64 || !b.ed25519Verify(edMsg, edSig, edKp.publicKey)) {
579
- return false
580
- }
581
- const edTampered = edSig.slice()
582
- edTampered[0] = (edTampered[0] ?? 0) ^ 0xff
583
- if (b.ed25519Verify(edMsg, edTampered, edKp.publicKey)) {
584
- return false
585
- }
586
- // EdDSA JWT round-trip on the SAME keypair (ttl=0 → no iat/exp, so verify
587
- // succeeds at any `now`).
588
- const edToken = b.jwtEdDSASign(enc.encode('{"sub":"ffi-self-test"}'), edKp.privateKey, 0, 0)
589
- if (edToken === null || edToken.split('.').length !== 3) {
590
- return false
591
- }
592
- const edClaims = b.jwtEdDSAVerify(enc.encode(edToken), edKp.publicKey, 0)
593
- if (edClaims === null || !edClaims.includes('"sub":"ffi-self-test"')) {
594
- return false
595
- }
596
- if (b.jwtEdDSAVerify(enc.encode('tampered.token.value'), edKp.publicKey, 0) !== null) {
597
- return false
598
- }
599
-
600
- // UrlBuilder opaque-handle: a null (0) handle must throw (the C side never
601
- // dereferences freed state) — exercises the symbol's ABI. Real resolve
602
- // parity is covered by test/unit/features/url-join.test.ts.
603
- let urlBuilderThrew = false
604
- try {
605
- b.urlBuilderResolve(0, enc.encode('g'))
606
- } catch {
607
- urlBuilderThrew = true
608
- }
609
- if (!urlBuilderThrew) {
610
- return false
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
-
628
- // rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
629
- let rlKeyThrew = false
630
- try {
631
- b.rateLimiterCheckKey(0, 12345, 0)
632
- } catch {
633
- rlKeyThrew = true
634
- }
635
- if (!rlKeyThrew) {
636
- return false
637
- }
638
-
639
- return true
640
- }
@@ -159,23 +159,19 @@ export function selfTestMetrics(b: BunFFI): boolean {
159
159
  return false
160
160
  }
161
161
 
162
- console.error('CK: counter')
163
162
  const gauge = b.metricsGauge(handle, 'ct_selftest_depth', 'q')
164
163
  if (gauge === METRICS_DECLARE_ERR) return false
165
164
  if (!b.metricsGaugeSet(handle, gauge, encodeUtf8('jobs'), 4)) return false
166
165
  if (!b.metricsGaugeSetStr(handle, gauge, 'io', 6)) return false
167
166
 
168
- console.error('CK: gauge')
169
167
  const hist = b.metricsHistogram(handle, 'ct_selftest_latency', '', '0.1,0.5')
170
168
  if (hist === METRICS_DECLARE_ERR) return false
171
169
  if (!b.metricsRecord(handle, hist, EMPTY, 0.25)) return false
172
170
  if (b.metricsRecord(handle, hist, EMPTY, -1)) return false // negatives rejected
173
171
 
174
- console.error('CK: hist')
175
172
  // Arity mismatch must fail safely.
176
173
  if (b.metricsRecord(handle, counter, encodeUtf8('/a'), 1)) return false
177
174
 
178
- console.error('CK: arity')
179
175
  // Render: probe with a small buffer → exact required size; then render.
180
176
  const probe = new Uint8Array(16)
181
177
  const needed = b.metricsRender(handle, probe)
@@ -195,7 +191,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
195
191
  return false
196
192
  }
197
193
 
198
- console.error('CK: render')
199
194
  // Snapshot: packed dump starts with version=1 + familyCount>=3.
200
195
  {
201
196
  const probe2 = new Uint8Array(16)
@@ -208,7 +203,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
208
203
  if (sv.getUint32(4, true) < 3) return false
209
204
  }
210
205
 
211
- console.error('CK: snapshot')
212
206
  // Batch: two entries in ONE crossing.
213
207
  {
214
208
  const c2 = b.metricsCounter(handle, 'ct_selftest_batch', 'k')
@@ -229,7 +223,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
229
223
  }
230
224
  }
231
225
 
232
- console.error('CK: batch')
233
226
  // A null handle must throw on declare (never dereference freed state).
234
227
  try {
235
228
  b.metricsCounter(0, 'ct_selftest_x', '')