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.
- package/Cargo.lock +4 -3
- 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 +1658 -1314
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/BENCHMARKS.md +13 -0
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +1 -1
- package/docs/FFI_BUN_GUIDE.md +13 -1
- package/docs/HOW_TO_ADD_AN_OP.md +24 -0
- package/docs/INGRESS.md +4 -4
- package/docs/REPO_MAP.md +8 -6
- 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 +170 -7
- package/rust/ffi/crypto.rs +182 -61
- 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 +197 -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 +39 -241
- package/src/native/ffi/build/metrics.ts +0 -7
- 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/types.ts +17 -0
- package/src/native/ffi.ts +2 -422
- 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'
|
|
@@ -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
|
-
|
|
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', '')
|