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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +501 -5
- package/dist/index.js +1648 -1325
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +1 -1
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +1 -1
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +7 -5
- package/docs/superpowers/plans/2026-09-18-castrum-hardening-cleanup.md +617 -0
- package/index.ts +1 -0
- package/package.json +3 -2
- package/rust/crypto/aead.rs +44 -0
- package/rust/crypto/argon2.rs +32 -2
- package/rust/crypto/jwt/tests.rs +19 -0
- package/rust/crypto/jwt/token.rs +43 -28
- package/rust/crypto/session.rs +6 -0
- package/rust/ffi/crypto.rs +63 -32
- package/rust/ffi/hashing.rs +48 -33
- package/rust/ffi/http.rs +62 -36
- package/rust/ffi/metrics.rs +6 -2
- package/rust/ffi/payload.rs +47 -27
- package/rust/ffi/task.rs +51 -32
- package/rust/ffi/tests.rs +24 -0
- package/rust/ffi/util.rs +40 -0
- package/rust/http/headers.rs +13 -0
- package/rust/http/multipart.rs +139 -0
- package/rust/ingress/native_route.rs +21 -3
- package/rust/task/completion.rs +7 -0
- package/rust/task/mod.rs +1 -1
- package/rust/task/tests.rs +30 -0
- package/src/ingress/decode/baked-result.ts +13 -5
- package/src/ingress/decode/fast-result.ts +1 -1
- package/src/ingress/decode/result-base.ts +16 -2
- package/src/ingress/handlers.ts +103 -31
- package/src/ingress/headers/memoized-headers.ts +40 -0
- package/src/ingress/native-route.ts +25 -6
- package/src/ingress/options.ts +26 -2
- package/src/ingress/packing/gather-raw-headers.ts +24 -8
- package/src/ingress/response/baked-response.ts +118 -28
- package/src/ingress/router.ts +14 -2
- package/src/ingress/routes/common.ts +26 -10
- package/src/ingress/routes/echo.ts +50 -7
- package/src/ingress/routes/json-write.ts +21 -8
- package/src/ingress/routes/responder.ts +4 -4
- package/src/ingress/server-node.ts +33 -14
- package/src/ingress/server.ts +3 -4
- package/src/ingress/shared.ts +1 -1
- package/src/ingress/types.ts +15 -0
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs-selftest.ts +305 -0
- package/src/native/ffi/build/codecs.ts +0 -296
- package/src/native/ffi/build/instances-selftest.ts +264 -0
- package/src/native/ffi/build/instances.ts +10 -259
- package/src/native/ffi/build/parse-selftest.ts +238 -0
- package/src/native/ffi/build/parse.ts +11 -234
- package/src/native/ffi/build.ts +5 -1
- package/src/native/ffi/selftest.ts +3 -3
- package/src/native/ffi/symbols.ts +460 -0
- package/src/native/ffi.ts +2 -434
- package/src/native/ingress-binding.ts +39 -0
- package/src/runtime/codec.ts +24 -0
- package/src/runtime/types.ts +11 -0
- package/src/rust-ffi/batch/build.ts +18 -8
- package/src/rust-ffi/batch/types.ts +15 -0
- package/src/rust-ffi/options.ts +19 -0
- package/src/rust-ffi/scalar/crypto.ts +8 -0
- package/src/rust-ffi/scalar/factories.ts +47 -20
- package/src/rust-ffi/scalar/http.ts +60 -10
- package/src/rust-ffi/scalar/interface.ts +4 -1
- package/src/rust-ffi/scalar/payload.ts +27 -1
- package/src/shared/buffer-pool.ts +44 -9
- package/src/shared/codec.ts +26 -0
- package/src/shared/packed/parsers.ts +64 -7
- 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
|
|
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
|
-
|
|
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
|
-
}
|