castrum 0.9.7 → 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 (84) hide show
  1. package/Cargo.lock +1 -1
  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 +1648 -1325
  13. package/docs/BENCHMARKS.md +13 -0
  14. package/docs/CASE_STUDY.md +1 -1
  15. package/docs/ENVIRONMENT.md +1 -1
  16. package/docs/FFI_BUN_GUIDE.md +1 -1
  17. package/docs/INGRESS.md +4 -4
  18. package/docs/REPO_MAP.md +7 -5
  19. package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
  20. package/index.ts +1 -0
  21. package/package.json +3 -2
  22. package/rust/crypto/aead.rs +44 -0
  23. package/rust/crypto/argon2.rs +32 -2
  24. package/rust/crypto/jwt/tests.rs +19 -0
  25. package/rust/crypto/jwt/token.rs +43 -28
  26. package/rust/crypto/session.rs +6 -0
  27. package/rust/ffi/crypto.rs +63 -32
  28. package/rust/ffi/hashing.rs +48 -33
  29. package/rust/ffi/http.rs +62 -36
  30. package/rust/ffi/metrics.rs +6 -2
  31. package/rust/ffi/payload.rs +47 -27
  32. package/rust/ffi/task.rs +51 -32
  33. package/rust/ffi/tests.rs +24 -0
  34. package/rust/ffi/util.rs +40 -0
  35. package/rust/http/headers.rs +13 -0
  36. package/rust/http/multipart.rs +139 -0
  37. package/rust/ingress/native_route.rs +21 -3
  38. package/rust/task/completion.rs +7 -0
  39. package/rust/task/mod.rs +1 -1
  40. package/rust/task/tests.rs +30 -0
  41. package/src/ingress/decode/baked-result.ts +13 -5
  42. package/src/ingress/decode/fast-result.ts +1 -1
  43. package/src/ingress/decode/result-base.ts +16 -2
  44. package/src/ingress/handlers.ts +103 -31
  45. package/src/ingress/headers/memoized-headers.ts +40 -0
  46. package/src/ingress/native-route.ts +25 -6
  47. package/src/ingress/options.ts +26 -2
  48. package/src/ingress/packing/gather-raw-headers.ts +24 -8
  49. package/src/ingress/response/baked-response.ts +118 -28
  50. package/src/ingress/router.ts +14 -2
  51. package/src/ingress/routes/common.ts +26 -10
  52. package/src/ingress/routes/echo.ts +50 -7
  53. package/src/ingress/routes/json-write.ts +21 -8
  54. package/src/ingress/routes/responder.ts +4 -4
  55. package/src/ingress/server-node.ts +33 -14
  56. package/src/ingress/server.ts +3 -4
  57. package/src/ingress/shared.ts +1 -1
  58. package/src/ingress/types.ts +15 -0
  59. package/src/loader/ops.ts +1 -1
  60. package/src/native/ffi/build/codecs-selftest.ts +305 -0
  61. package/src/native/ffi/build/codecs.ts +0 -296
  62. package/src/native/ffi/build/instances-selftest.ts +264 -0
  63. package/src/native/ffi/build/instances.ts +10 -259
  64. package/src/native/ffi/build/parse-selftest.ts +238 -0
  65. package/src/native/ffi/build/parse.ts +11 -234
  66. package/src/native/ffi/build.ts +5 -1
  67. package/src/native/ffi/selftest.ts +3 -3
  68. package/src/native/ffi/symbols.ts +460 -0
  69. package/src/native/ffi.ts +2 -434
  70. package/src/native/ingress-binding.ts +39 -0
  71. package/src/runtime/codec.ts +24 -0
  72. package/src/runtime/types.ts +11 -0
  73. package/src/rust-ffi/batch/build.ts +18 -8
  74. package/src/rust-ffi/batch/types.ts +15 -0
  75. package/src/rust-ffi/options.ts +19 -0
  76. package/src/rust-ffi/scalar/crypto.ts +8 -0
  77. package/src/rust-ffi/scalar/factories.ts +47 -20
  78. package/src/rust-ffi/scalar/http.ts +60 -10
  79. package/src/rust-ffi/scalar/interface.ts +4 -1
  80. package/src/rust-ffi/scalar/payload.ts +27 -1
  81. package/src/shared/buffer-pool.ts +44 -9
  82. package/src/shared/codec.ts +26 -0
  83. package/src/shared/packed/parsers.ts +64 -7
  84. 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'
@@ -83,8 +83,12 @@ export function buildInstances(
83
83
  // Server-preference tie-breaking (RFC 7231 server semantics). The C ABI
84
84
  // takes `header` as a `cstring` ARG, so decode the bytes to a JS string
85
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.
86
88
  // `null` = identity (napi Option parity).
87
- return acceptNegotiatorNegotiateServerRaw(inner, decodeUtf8(header))
89
+ const text = decodeUtf8(header)
90
+ if (hasNul(text)) return null
91
+ return acceptNegotiatorNegotiateServerRaw(inner, text)
88
92
  },
89
93
  jwtSignerSign(inner, claimsJson, nowSeconds) {
90
94
  // Precompiled key + ttl → compact token. 0 = invalid claims JSON (real
@@ -196,7 +200,10 @@ export function buildInstances(
196
200
  rateLimiterCheck(inner, key, nowMs) {
197
201
  // Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
198
202
  // Reused scratch + cached DataView (no per-call allocs). `key` is a
199
- // `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 }
200
207
  const out = rateScratch
201
208
  const w = Number(
202
209
  rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
@@ -429,259 +436,3 @@ export function buildInstances(
429
436
  },
430
437
  }
431
438
  }
432
-
433
- /**
434
- * Bind-time self-test for the opaque-handle surface (the methods built in
435
- * `buildInstances`). `false` disables the ffi layer and forces the napi
436
- * fallback.
437
- */
438
- export function selfTestInstances(b: BunFFI): boolean {
439
- const enc = { encode: encodeUtf8 }
440
- const dec = { decode: decodeUtf8 }
441
-
442
- // Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
443
- // `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
444
- // Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
445
- // field against output.rs. Slot order mirrors the struct field order.
446
- const layoutBuf = new Uint8Array(38 * 4)
447
- b.ingressLayout(layoutBuf)
448
- const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
449
- if (
450
- layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
451
- layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
452
- layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
453
- layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
454
- layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
455
- layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
456
- ) {
457
- return false
458
- }
459
-
460
- // Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
461
- // 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
462
- // count/types/return) at bind time — a signature drift would surface here
463
- // instead of crashing under load. Real frame→output parity is covered by
464
- // ffi.test.ts against a live napi instance.
465
- try {
466
- b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
467
- return false // a null handle must throw, not return
468
- } catch {
469
- // expected: null inner handle → 0 → throw
470
- }
471
-
472
- // Ingress raw-components C-ABI: with a null (0) inner handle the Rust side
473
- // returns 0 immediately and the wrapper throws — exercises the 12-arg ABI
474
- // (incl. the two `cstring` slots for url/ip) at bind time. Real frame→output
475
- // parity is covered by ffi.test.ts against a live napi instance.
476
- try {
477
- b.ingressHandleComponents(
478
- 0,
479
- 0, // GET
480
- '/',
481
- '',
482
- enc.encode('rid'),
483
- new Uint8Array(2), // empty packed headers [u16 0]
484
- null,
485
- new Uint8Array(64),
486
- )
487
- return false // a null handle must throw, not return
488
- } catch {
489
- // expected: null inner handle → 0 → throw
490
- }
491
-
492
- // Per-route native stack (castrum_route_*): compile a parseQuery-only
493
- // descriptor, run one frame, assert the packed verdict. Exercises all three
494
- // symbols' ABI at bind time (handle return, run needed-size, destroy).
495
- const routeDesc = new Uint8Array(33)
496
- const rd = new DataView(routeDesc.buffer)
497
- rd.setUint32(0, 0x524f5554, true) // ROUTE_DESC_MAGIC "ROUT"
498
- rd.setUint32(4, 3, true) // ROUTE_DESC_VERSION
499
- rd.setUint32(8, 2 * 1024 * 1024, true) // maxBodyBytes
500
- rd.setUint32(12, 8192, true) // maxQueryBytes
501
- rd.setUint32(16, 8192, true) // maxCookieBytes
502
- rd.setUint32(20, 0, true) // maxPairs
503
- rd.setUint32(24, 1, true) // stageCount
504
- routeDesc[28] = 0 // parseQuery
505
- rd.setUint32(29, 0, true) // schemaCount
506
- const routeHandle = b.routeCompile(routeDesc)
507
- if (routeHandle === 0) {
508
- return false
509
- }
510
- const routeFrame = new Uint8Array(15)
511
- const rf = new DataView(routeFrame.buffer)
512
- rf.setUint32(0, 0, true) // flags (no body)
513
- rf.setUint32(4, 3, true) // qLen
514
- routeFrame.set(enc.encode('a=1'), 8)
515
- rf.setUint32(11, 0, true) // cLen
516
- const routeOut = new Uint8Array(64)
517
- const routeW = b.routeRun(routeHandle, routeFrame, routeOut)
518
- if (routeW <= 8) {
519
- return false // header + a query pair section must exceed 8 bytes
520
- }
521
- const rv = new DataView(routeOut.buffer)
522
- const rFlags = rv.getUint32(0, true)
523
- if ((rFlags & 0b1) === 0 || (rFlags & 0b100) === 0) {
524
- return false // OK + QUERY_VALID bits must be set
525
- }
526
- b.routeDestroy(routeHandle)
527
-
528
- // ConditionalRequest opaque-handle: a null (0) handle must return false (the
529
- // C side never dereferences freed state) — exercises the symbol's ABI. Real
530
- // verdict parity is covered by test/unit/features/etag.test.ts.
531
- if (b.conditionalIsNotModified(0, null, null) !== false) {
532
- return false
533
- }
534
-
535
- // Phase 6 opaque-handle instances: null (0) handles must fail SAFELY (false /
536
- // null / throw — never dereference freed state). Real verdict parity is
537
- // covered by the per-instance JS tests.
538
- if (b.mediaTypeMatcherMatches(0, enc.encode('x')) !== false) return false
539
- if (b.acceptNegotiatorNegotiate(0, enc.encode('gzip')) !== null) return false
540
- if (b.acceptNegotiatorNegotiateServer(0, enc.encode('gzip')) !== null) return false
541
- if (b.schemaValidatorValidate(0, enc.encode('{}')) !== false) return false
542
- if (b.jwtSignerVerify(0, enc.encode('a.b.c'), 0) !== null) return false
543
- let phase6Threw = false
544
- try {
545
- b.jwtSignerSign(0, enc.encode('{}'), 0)
546
- } catch {
547
- phase6Threw = true
548
- }
549
- if (!phase6Threw) return false
550
- phase6Threw = false
551
- try {
552
- b.templateRender(0, enc.encode('{}'))
553
- } catch {
554
- phase6Threw = true
555
- }
556
- if (!phase6Threw) return false
557
- phase6Threw = false
558
- try {
559
- b.rateLimiterCheck(0, 'k', 0)
560
- } catch {
561
- phase6Threw = true
562
- }
563
- if (!phase6Threw) return false
564
-
565
- // JWT sign pooled Into: same token as the cstring path.
566
- const jwtClaims = enc.encode('{"sub":"user-1"}')
567
- const jwtSecret2 = enc.encode('my-secret')
568
- const jwtOut = new Uint8Array(512)
569
- const jwtW = b.jwtSignBytesInto(jwtClaims, jwtSecret2, 0, 0, jwtOut)
570
- if (jwtW === 0 || dec.decode(jwtOut.subarray(0, jwtW)).split('.').length !== 3) {
571
- return false
572
- }
573
-
574
- // JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
575
- // signature with the FFI HMAC to prove the binding is real.
576
- const jwtSecret = enc.encode('my-secret')
577
- const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
578
- const jwtStr = jwt
579
- const segs = jwtStr.split('.')
580
- if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
581
- return false
582
- }
583
- const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
584
- const sigHex = b.hmacSha256(jwtSecret, signingInput)
585
- const sigBytes = b.base64Decode(enc.encode(segs[2] ?? ''), true, false)
586
- if (sigBytes === null || b.hexEncode(sigBytes) !== sigHex) {
587
- return false
588
- }
589
-
590
- // jwtVerify: sign (ttl=0 → no iat/exp) then verify → claims JSON; tampered → null.
591
- const vjwtSecret = enc.encode('verify-secret')
592
- const vjwt = b.jwtSignBytes(enc.encode('{"sub":"u-1"}'), vjwtSecret, 0, 0)
593
- const vjwtClaims = b.jwtVerify(enc.encode(vjwt), vjwtSecret, 0)
594
- if (vjwtClaims === null || !vjwtClaims.includes('"sub":"u-1"')) {
595
- return false
596
- }
597
- if (b.jwtVerify(enc.encode('tampered.token.value'), vjwtSecret, 0) !== null) {
598
- return false
599
- }
600
-
601
- // Ed25519 round-trip: generate → sign → verify (and a tampered signature
602
- // must be rejected). The generate wrapper also parses the packed blob, so
603
- // this pins the `[u32 privLen][priv][u32 pubLen][pub]` layout.
604
- const edKp = b.ed25519GenerateKeypair()
605
- const edMsg = enc.encode('ed25519 bind-time self-test')
606
- const edSig = b.ed25519Sign(edMsg, edKp.privateKey)
607
- if (edSig.byteLength !== 64 || !b.ed25519Verify(edMsg, edSig, edKp.publicKey)) {
608
- return false
609
- }
610
- const edTampered = edSig.slice()
611
- edTampered[0] = (edTampered[0] ?? 0) ^ 0xff
612
- if (b.ed25519Verify(edMsg, edTampered, edKp.publicKey)) {
613
- return false
614
- }
615
- // EdDSA JWT round-trip on the SAME keypair (ttl=0 → no iat/exp, so verify
616
- // succeeds at any `now`).
617
- const edToken = b.jwtEdDSASign(enc.encode('{"sub":"ffi-self-test"}'), edKp.privateKey, 0, 0)
618
- if (edToken === null || edToken.split('.').length !== 3) {
619
- return false
620
- }
621
- const edClaims = b.jwtEdDSAVerify(enc.encode(edToken), edKp.publicKey, 0)
622
- if (edClaims === null || !edClaims.includes('"sub":"ffi-self-test"')) {
623
- return false
624
- }
625
- if (b.jwtEdDSAVerify(enc.encode('tampered.token.value'), edKp.publicKey, 0) !== null) {
626
- return false
627
- }
628
-
629
- // UrlBuilder opaque-handle: a null (0) handle must throw (the C side never
630
- // dereferences freed state) — exercises the symbol's ABI. Real resolve
631
- // parity is covered by test/unit/features/url-join.test.ts.
632
- let urlBuilderThrew = false
633
- try {
634
- b.urlBuilderResolve(0, enc.encode('g'))
635
- } catch {
636
- urlBuilderThrew = true
637
- }
638
- if (!urlBuilderThrew) {
639
- return false
640
- }
641
- // Wire-validate: null inner (0) → false (ABI exercise); real parity lives
642
- // in ffi.test.ts with a live compiled instance.
643
- if (b.queryValidate(0, 'route=/a') !== false) return false
644
- if (b.cookieValidate(0, 'route=/a') !== false) return false
645
-
646
- // Session seal/open round trip through the C ABI.
647
- {
648
- const tok = b.sessionSeal('sess-9', '{"n":1}', 1234567, 'sekrit')
649
- if (tok === null || !tok.includes('.') || !tok.startsWith('{"id":"sess-9"')) return false
650
- const out = new Uint8Array(256)
651
- const w = b.sessionOpen(tok, 'sekrit', out)
652
- if (w <= 13 || out[0] !== 1) return false
653
- // bad signature → 0
654
- if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
655
-
656
- // Byte-arg siblings. Both forms share ONE core, so the sealed token must be
657
- // byte-identical — the strongest available parity check between the two.
658
- const enc = new TextEncoder()
659
- const tokB = b.sessionSealBytes(
660
- enc.encode('sess-9'),
661
- enc.encode('{"n":1}'),
662
- 1234567,
663
- enc.encode('sekrit'),
664
- )
665
- if (tokB === null || tokB !== tok) return false
666
- const outB = new Uint8Array(256)
667
- const wB = b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB)
668
- if (wB !== w || outB[0] !== 1) return false
669
- // Bad signature → 0; a too-small buffer reports the EXACT size, not 0.
670
- if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('wrong'), outB) !== 0) return false
671
- if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB.subarray(0, 4)) !== wB)
672
- return false
673
- }
674
-
675
- // rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
676
- let rlKeyThrew = false
677
- try {
678
- b.rateLimiterCheckKey(0, 12345, 0)
679
- } catch {
680
- rlKeyThrew = true
681
- }
682
- if (!rlKeyThrew) {
683
- return false
684
- }
685
-
686
- return true
687
- }