castrum 0.9.0 → 0.9.2
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/README.md +153 -630
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1653 -1817
- package/dist/index.js +6172 -4294
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +111 -17
- package/docs/BENCHMARKS.md +117 -49
- package/docs/CASE_STUDY.md +243 -0
- package/docs/ENVIRONMENT.md +24 -7
- package/docs/FFI_BUN_GUIDE.md +335 -0
- package/docs/GETTING_STARTED.md +18 -34
- package/docs/HOW_TO_ADD_AN_OP.md +180 -0
- package/docs/INGRESS-ROUTER.md +82 -0
- package/docs/INGRESS.md +11 -7
- package/docs/NATIVE-ROUTE.md +77 -0
- package/docs/REPO_MAP.md +103 -53
- package/docs/adr/0001-two-wire-formats.md +1 -1
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/ai/LOCAL_DEV.md +81 -0
- package/docs/ai/TREE.md +1070 -0
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +41 -28
- package/src/ingress/body.ts +167 -27
- package/src/ingress/decode/result-base.ts +10 -2
- package/src/ingress/handlers.ts +116 -285
- package/src/ingress/headers/security.ts +41 -0
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +45 -169
- package/src/ingress/native-route.ts +182 -0
- package/src/ingress/packing/gather-raw-headers.ts +110 -22
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/route-wire.ts +197 -0
- package/src/ingress/packing/select-headers.ts +20 -7
- package/src/ingress/path-matcher.ts +115 -0
- package/src/ingress/response/baked-response.ts +302 -0
- package/src/ingress/router.ts +277 -0
- package/src/ingress/routes/common.ts +50 -1
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +3 -13
- package/src/ingress/routes/index.ts +5 -4
- package/src/ingress/routes/json-write.ts +26 -47
- package/src/ingress/routes/native.ts +130 -0
- package/src/ingress/routes/options.ts +2 -12
- package/src/ingress/routes/read.ts +3 -13
- package/src/ingress/routes/responder.ts +159 -0
- package/src/ingress/server-node.ts +7 -7
- package/src/ingress/server.ts +83 -112
- package/src/ingress/sync.ts +151 -0
- package/src/ingress/types.ts +44 -1
- package/src/integration/pipeline.ts +3 -0
- package/src/integration/streaming.ts +2 -3
- package/src/integration/websocket.ts +4 -3
- package/src/loader/batch.ts +4 -0
- package/src/loader/cost.ts +1 -0
- package/src/loader/index.ts +43 -228
- package/src/loader/state.ts +84 -0
- package/src/loader/types.ts +145 -0
- package/src/native/ffi/build/codecs.ts +736 -0
- package/src/native/ffi/build/compress.ts +248 -0
- package/src/native/ffi/build/instances.ts +598 -0
- package/src/native/ffi/build/parse.ts +539 -0
- package/src/native/ffi/build/util.ts +140 -0
- package/src/native/ffi/build.ts +94 -0
- package/src/native/ffi/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +22 -0
- package/src/native/ffi/types.ts +499 -0
- package/src/native/ffi.ts +307 -1357
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +279 -0
- package/src/native/types.ts +41 -238
- package/src/runtime/builtins.ts +131 -0
- package/src/runtime/codec.ts +147 -0
- package/src/runtime/detect.ts +36 -0
- package/src/runtime/index.ts +28 -0
- package/src/runtime/native.ts +35 -0
- package/src/runtime/server.ts +31 -0
- package/src/runtime/transport.ts +64 -0
- package/src/runtime/types.ts +173 -0
- package/src/runtime/uuid.ts +45 -0
- package/src/runtime/websocket.ts +30 -0
- package/src/rust-ffi/addon.ts +1 -1
- package/src/rust-ffi/batch/build.ts +155 -0
- package/src/rust-ffi/batch/index.ts +8 -0
- package/src/rust-ffi/batch/types.ts +92 -0
- package/src/rust-ffi/client.ts +5 -6
- package/src/rust-ffi/context.ts +39 -10
- package/src/rust-ffi/index.ts +28 -17
- package/src/rust-ffi/into.ts +21 -0
- package/src/rust-ffi/options.ts +1 -1
- package/src/rust-ffi/packed.ts +40 -2
- package/src/rust-ffi/proven.ts +16 -30
- package/src/rust-ffi/scalar/crypto.ts +188 -107
- package/src/rust-ffi/scalar/factories.ts +257 -27
- package/src/rust-ffi/scalar/hashing.ts +43 -41
- package/src/rust-ffi/scalar/http.ts +208 -77
- package/src/rust-ffi/scalar/index.ts +3 -3
- package/src/rust-ffi/scalar/interface.ts +88 -213
- package/src/rust-ffi/scalar/json-packed.ts +175 -0
- package/src/rust-ffi/scalar/json.ts +28 -12
- package/src/rust-ffi/scalar/payload.ts +159 -42
- package/src/rust-ffi/text.ts +31 -5
- package/src/selection.ts +27 -31
- package/src/shared/adaptive.ts +1 -2
- package/src/shared/buffer-pool.ts +72 -17
- package/src/shared/bytes.ts +75 -6
- package/src/shared/codec.ts +78 -0
- package/src/shared/env.ts +4 -4
- package/src/shared/log.ts +1 -0
- package/src/shared/packed/index.ts +10 -0
- package/src/shared/packed/parsers.ts +31 -0
- package/src/shared/packed/schema.ts +23 -0
- package/src/shared/{packed.ts → packed/wire.ts} +31 -39
- package/src/shared/proven.ts +189 -496
- package/src/shared/runtime.ts +20 -25
- package/src/shared/uuid.ts +12 -28
- package/src/data/json-rows.ts +0 -33
- package/src/rust-ffi/batch.ts +0 -431
- package/src/shared/bench-classify.ts +0 -209
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// src/native/ffi/build/instances.ts — opaque-handle (compiled-instance) BunFFI methods.
|
|
2
|
+
//
|
|
3
|
+
// Every method here evaluates against a PRECOMPILED native instance via its
|
|
4
|
+
// opaque `inner_ptr()` handle (JWT signer, template renderer, schema validator,
|
|
5
|
+
// rate limiter, media-type matcher, accept negotiator, conditional request, URL
|
|
6
|
+
// builder) or drives a compiled route / the full ingress pipeline. Receives the
|
|
7
|
+
// raw dlopen'd symbols and the per-bind context from `build()`.
|
|
8
|
+
|
|
9
|
+
import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
|
|
10
|
+
import { EMPTY_VIEW } from '../constants'
|
|
11
|
+
import type { BunFFI, Raw2, Raw3, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
|
|
12
|
+
import type { BuildCtx } from './util'
|
|
13
|
+
import { cstr, growExact, unpackRateCheck } from './util'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build the opaque-handle methods of the BunFFI surface. `ctx` is destructured
|
|
17
|
+
* so the method bodies read exactly as the original `build()`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildInstances(
|
|
20
|
+
sym: Record<string, (...a: unknown[]) => unknown>,
|
|
21
|
+
ctx: BuildCtx,
|
|
22
|
+
): Partial<BunFFI> {
|
|
23
|
+
const { lenOrView, rateScratch, rateScratchView } = ctx
|
|
24
|
+
|
|
25
|
+
const conditionalIsNotModifiedRaw = sym.castrum_conditional_is_not_modified as Raw6
|
|
26
|
+
const mediaTypeMatcherMatchesRaw = sym.castrum_media_type_matcher_matches as Raw3
|
|
27
|
+
const acceptNegotiatorNegotiateRaw = sym.castrum_accept_negotiator_negotiate as RawCStr
|
|
28
|
+
const acceptNegotiatorNegotiateServerRaw = sym
|
|
29
|
+
.castrum_accept_negotiator_negotiate_server as (...a: unknown[]) => string | null
|
|
30
|
+
const ed25519GenerateKeypairRaw = sym.castrum_ed25519_generate_keypair as Raw2
|
|
31
|
+
const ed25519SignRaw = sym.castrum_ed25519_sign as Raw6
|
|
32
|
+
const ed25519VerifyRaw = sym.castrum_ed25519_verify as Raw6
|
|
33
|
+
const jwtEdDSASignSym = sym.castrum_jwt_eddsa_sign as (...a: unknown[]) => string | null
|
|
34
|
+
const jwtEdDSAVerifySym = sym.castrum_jwt_eddsa_verify as (...a: unknown[]) => string | null
|
|
35
|
+
const jwtSignerSignRaw = sym.castrum_jwt_signer_sign as Raw6
|
|
36
|
+
const jwtSignerVerifyRaw = sym.castrum_jwt_signer_verify as Raw6
|
|
37
|
+
const templateRenderRaw = sym.castrum_template_render as Raw5
|
|
38
|
+
const schemaValidatorValidateRaw = sym.castrum_schema_validator_validate as Raw3
|
|
39
|
+
const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
|
|
40
|
+
const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
|
|
41
|
+
const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
|
|
42
|
+
const jwtSignBytesInto = sym.castrum_jwt_sign_bytes_into as Raw8
|
|
43
|
+
const jwtVerifySym = sym.castrum_jwt_verify as (...a: unknown[]) => string | null
|
|
44
|
+
const urlBuilderResolveRaw = sym.castrum_url_builder_resolve as Raw5
|
|
45
|
+
const ingressHandlePacked = sym.castrum_ingress_handle_packed as Raw7
|
|
46
|
+
const ingressHandleComponentsSym = sym.castrum_ingress_handle_components as Raw12
|
|
47
|
+
const ingressLayoutSym = sym.castrum_ingress_layout as Raw2
|
|
48
|
+
const routeCompileSym = sym.castrum_route_compile as Raw2
|
|
49
|
+
const routeRunSym = sym.castrum_route_run as Raw5
|
|
50
|
+
const routeDestroySym = sym.castrum_route_destroy as (a: unknown) => void
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
conditionalIsNotModified(inner, ifNoneMatch, ifModifiedSince) {
|
|
54
|
+
// Opaque-handle eval of the precompiled `ConditionalRequest` state.
|
|
55
|
+
// flags bit0 = If-None-Match present, bit1 = If-Modified-Since present
|
|
56
|
+
// (present-but-empty is distinct from absent — napi Option parity). A
|
|
57
|
+
// null handle (0) → 0 (never dereferences freed state).
|
|
58
|
+
const flags = (ifNoneMatch === null ? 0 : 1) | (ifModifiedSince === null ? 0 : 2)
|
|
59
|
+
const inm = ifNoneMatch ?? EMPTY_VIEW
|
|
60
|
+
const ims = ifModifiedSince ?? EMPTY_VIEW
|
|
61
|
+
return (
|
|
62
|
+
Number(
|
|
63
|
+
conditionalIsNotModifiedRaw(inner, inm, lenOrView(inm), ims, lenOrView(ims), flags),
|
|
64
|
+
) === 1
|
|
65
|
+
)
|
|
66
|
+
},
|
|
67
|
+
mediaTypeMatcherMatches(inner, actual) {
|
|
68
|
+
// Precompiled expected-type match → u8.
|
|
69
|
+
return Number(mediaTypeMatcherMatchesRaw(inner, actual, lenOrView(actual))) === 1
|
|
70
|
+
},
|
|
71
|
+
acceptNegotiatorNegotiate(inner, header) {
|
|
72
|
+
// cstring best-supported encoding; `null` = identity (napi Option parity).
|
|
73
|
+
return acceptNegotiatorNegotiateRaw(inner, header, lenOrView(header))
|
|
74
|
+
},
|
|
75
|
+
acceptNegotiatorNegotiateServer(inner, header) {
|
|
76
|
+
// Server-preference tie-breaking (RFC 7231 server semantics). The C ABI
|
|
77
|
+
// takes `header` as a `cstring` ARG, so decode the bytes to a JS string
|
|
78
|
+
// (the engine transcodes it to the call-scoped NUL-terminated buffer).
|
|
79
|
+
// `null` = identity (napi Option parity).
|
|
80
|
+
return acceptNegotiatorNegotiateServerRaw(inner, decodeUtf8(header))
|
|
81
|
+
},
|
|
82
|
+
jwtSignerSign(inner, claimsJson, nowSeconds) {
|
|
83
|
+
// Precompiled key + ttl → compact token. 0 = invalid claims JSON (real
|
|
84
|
+
// error → growExact throws); w > output.length = exact needed size.
|
|
85
|
+
return growExact(
|
|
86
|
+
(out) =>
|
|
87
|
+
Number(
|
|
88
|
+
jwtSignerSignRaw(
|
|
89
|
+
inner,
|
|
90
|
+
claimsJson,
|
|
91
|
+
lenOrView(claimsJson),
|
|
92
|
+
BigInt(nowSeconds),
|
|
93
|
+
out,
|
|
94
|
+
lenOrView(out),
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
Math.min(claimsJson.length + 128, 64 * 1024),
|
|
98
|
+
1024 * 1024,
|
|
99
|
+
'jwt signer: invalid claims JSON or output buffer too small',
|
|
100
|
+
)
|
|
101
|
+
},
|
|
102
|
+
jwtSignerVerify(inner, token, nowSeconds) {
|
|
103
|
+
// Precompiled key → claims JSON bytes; 0 = invalid / expired → null.
|
|
104
|
+
const out = new Uint8Array(Math.min(token.length + 256, 64 * 1024))
|
|
105
|
+
const w = Number(
|
|
106
|
+
jwtSignerVerifyRaw(inner, token, lenOrView(token), BigInt(nowSeconds), out, lenOrView(out)),
|
|
107
|
+
)
|
|
108
|
+
if (w === 0) return null
|
|
109
|
+
if (w > out.length) {
|
|
110
|
+
const out2 = new Uint8Array(w)
|
|
111
|
+
const w2 = Number(
|
|
112
|
+
jwtSignerVerifyRaw(
|
|
113
|
+
inner,
|
|
114
|
+
token,
|
|
115
|
+
lenOrView(token),
|
|
116
|
+
BigInt(nowSeconds),
|
|
117
|
+
out2,
|
|
118
|
+
lenOrView(out2),
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
return w2 === 0 ? null : out2.subarray(0, w2)
|
|
122
|
+
}
|
|
123
|
+
return out.subarray(0, w)
|
|
124
|
+
},
|
|
125
|
+
templateRender(inner, contextJson) {
|
|
126
|
+
// Compiled template + pre-serialized JSON context → UTF-8 bytes. 0 =
|
|
127
|
+
// invalid context / render error (real error → growExact throws).
|
|
128
|
+
return growExact(
|
|
129
|
+
(out) =>
|
|
130
|
+
Number(
|
|
131
|
+
templateRenderRaw(inner, contextJson, lenOrView(contextJson), out, lenOrView(out)),
|
|
132
|
+
),
|
|
133
|
+
Math.min(contextJson.length + 128, 64 * 1024),
|
|
134
|
+
1024 * 1024,
|
|
135
|
+
'template render: invalid context JSON or render failed',
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
schemaValidatorValidate(inner, doc) {
|
|
139
|
+
return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
|
|
140
|
+
},
|
|
141
|
+
rateLimiterCheck(inner, key, nowMs) {
|
|
142
|
+
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
143
|
+
// Reused scratch + cached DataView (no per-call allocs). `key` is a
|
|
144
|
+
// `cstring` ARG (the engine transcodes the JS string in-engine).
|
|
145
|
+
const out = rateScratch
|
|
146
|
+
const w = Number(
|
|
147
|
+
rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
|
|
148
|
+
)
|
|
149
|
+
if (w === 0) throw new Error('rate limiter check: null handle')
|
|
150
|
+
return unpackRateCheck(out, rateScratchView)
|
|
151
|
+
},
|
|
152
|
+
rateLimiterCheckKey(inner, key, nowMs) {
|
|
153
|
+
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
154
|
+
// Reused scratch + cached DataView (no per-call allocs).
|
|
155
|
+
const out = rateScratch
|
|
156
|
+
const w = Number(
|
|
157
|
+
rateLimiterCheckKeyRaw(inner, BigInt(key), BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
|
|
158
|
+
)
|
|
159
|
+
if (w === 0) throw new Error('rate limiter check: null handle')
|
|
160
|
+
return unpackRateCheck(out, rateScratchView)
|
|
161
|
+
},
|
|
162
|
+
jwtSignBytes(claimsJson, secret, ttl, now) {
|
|
163
|
+
// Compact HS256 token returned as a cstring (engine-cloned) — the Rust
|
|
164
|
+
// side builds the whole token and the JS pays zero decode. ttl<=0 = no
|
|
165
|
+
// iat/exp (napi Option<i64> sentinel). i64 args must be BigInt.
|
|
166
|
+
return cstr(
|
|
167
|
+
jwtSignBytes(
|
|
168
|
+
claimsJson,
|
|
169
|
+
lenOrView(claimsJson),
|
|
170
|
+
secret,
|
|
171
|
+
lenOrView(secret),
|
|
172
|
+
BigInt(ttl),
|
|
173
|
+
BigInt(now),
|
|
174
|
+
),
|
|
175
|
+
'jwt sign: invalid claims JSON',
|
|
176
|
+
)
|
|
177
|
+
},
|
|
178
|
+
jwtSignBytesInto(claimsJson, secret, ttl, now, output) {
|
|
179
|
+
// Native pooled `_into`: writes the compact token directly into the
|
|
180
|
+
// caller buffer (no cstring round-trip). Needed-size convention: a write
|
|
181
|
+
// larger than `output.length` reports the exact required size → throw;
|
|
182
|
+
// 0 = invalid claims JSON.
|
|
183
|
+
const w = Number(
|
|
184
|
+
jwtSignBytesInto(
|
|
185
|
+
claimsJson,
|
|
186
|
+
lenOrView(claimsJson),
|
|
187
|
+
secret,
|
|
188
|
+
lenOrView(secret),
|
|
189
|
+
BigInt(ttl),
|
|
190
|
+
BigInt(now),
|
|
191
|
+
output,
|
|
192
|
+
lenOrView(output),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
if (w === 0) {
|
|
196
|
+
throw new Error('jwt sign: invalid claims JSON')
|
|
197
|
+
}
|
|
198
|
+
if (w > output.length) {
|
|
199
|
+
throw new Error('jwt sign: output buffer too small')
|
|
200
|
+
}
|
|
201
|
+
return w
|
|
202
|
+
},
|
|
203
|
+
jwtVerify(token, secret, nowSeconds) {
|
|
204
|
+
// Verify an HS256 JWT → claims as a JSON cstring; `null` = invalid
|
|
205
|
+
// signature / expired / malformed (napi Option parity). `now` is an i64
|
|
206
|
+
// C arg → must be passed as BigInt.
|
|
207
|
+
return jwtVerifySym(token, lenOrView(token), secret, lenOrView(secret), BigInt(nowSeconds))
|
|
208
|
+
},
|
|
209
|
+
ed25519GenerateKeypair() {
|
|
210
|
+
// Packed `[u32 privLen][priv PKCS#8 v1 DER][u32 pubLen][pub SPKI DER]`
|
|
211
|
+
// (100 bytes). Needed-size convention: `0` = CSPRNG failure (throw), a
|
|
212
|
+
// write larger than the buffer = the exact required size (one exact
|
|
213
|
+
// retry). Decode the packed blob into the two DER byte slices.
|
|
214
|
+
const blob = growExact(
|
|
215
|
+
(out) => Number(ed25519GenerateKeypairRaw(out, lenOrView(out))),
|
|
216
|
+
100,
|
|
217
|
+
1024,
|
|
218
|
+
'ed25519 keypair generation failed',
|
|
219
|
+
)
|
|
220
|
+
const privLen =
|
|
221
|
+
(blob[0] ?? 0) | ((blob[1] ?? 0) << 8) | ((blob[2] ?? 0) << 16) | ((blob[3] ?? 0) << 24)
|
|
222
|
+
const privateKey = blob.subarray(4, 4 + privLen)
|
|
223
|
+
const pubStart = 4 + privLen
|
|
224
|
+
const pubLen =
|
|
225
|
+
(blob[pubStart] ?? 0) |
|
|
226
|
+
((blob[pubStart + 1] ?? 0) << 8) |
|
|
227
|
+
((blob[pubStart + 2] ?? 0) << 16) |
|
|
228
|
+
((blob[pubStart + 3] ?? 0) << 24)
|
|
229
|
+
return { privateKey, publicKey: blob.subarray(pubStart + 4, pubStart + 4 + pubLen) }
|
|
230
|
+
},
|
|
231
|
+
ed25519Sign(msg, privateKey) {
|
|
232
|
+
// 64-byte signature (needed-size convention: `0` = invalid private key →
|
|
233
|
+
// growExact throws).
|
|
234
|
+
return growExact(
|
|
235
|
+
(out) =>
|
|
236
|
+
Number(
|
|
237
|
+
ed25519SignRaw(
|
|
238
|
+
privateKey,
|
|
239
|
+
lenOrView(privateKey),
|
|
240
|
+
msg,
|
|
241
|
+
lenOrView(msg),
|
|
242
|
+
out,
|
|
243
|
+
lenOrView(out),
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
64,
|
|
247
|
+
64,
|
|
248
|
+
'ed25519 sign failed (invalid private key)',
|
|
249
|
+
)
|
|
250
|
+
},
|
|
251
|
+
ed25519Verify(msg, signature, publicKey) {
|
|
252
|
+
// u8 → boolean. C ABI arg order is (key, msg, sig).
|
|
253
|
+
return (
|
|
254
|
+
Number(
|
|
255
|
+
ed25519VerifyRaw(
|
|
256
|
+
publicKey,
|
|
257
|
+
lenOrView(publicKey),
|
|
258
|
+
msg,
|
|
259
|
+
lenOrView(msg),
|
|
260
|
+
signature,
|
|
261
|
+
lenOrView(signature),
|
|
262
|
+
),
|
|
263
|
+
) === 1
|
|
264
|
+
)
|
|
265
|
+
},
|
|
266
|
+
jwtEdDSASign(claimsJson, privateKey, ttl, nowSeconds) {
|
|
267
|
+
// Compact EdDSA token as a cstring (engine clone); `null` = invalid
|
|
268
|
+
// claims JSON / invalid private key (the napi `jwt_sign_eddsa` throws,
|
|
269
|
+
// so a future public consumer maps null → throw). `ttl <= 0` = no
|
|
270
|
+
// `iat`/`exp` injection; `now`/`ttl` are i64 C args → BigInt.
|
|
271
|
+
return jwtEdDSASignSym(
|
|
272
|
+
claimsJson,
|
|
273
|
+
lenOrView(claimsJson),
|
|
274
|
+
privateKey,
|
|
275
|
+
lenOrView(privateKey),
|
|
276
|
+
BigInt(ttl),
|
|
277
|
+
BigInt(nowSeconds),
|
|
278
|
+
)
|
|
279
|
+
},
|
|
280
|
+
jwtEdDSAVerify(token, publicKey, nowSeconds) {
|
|
281
|
+
// Claims JSON as a cstring; `null` = invalid signature / expired /
|
|
282
|
+
// malformed (napi Option parity).
|
|
283
|
+
return jwtEdDSAVerifySym(
|
|
284
|
+
token,
|
|
285
|
+
lenOrView(token),
|
|
286
|
+
publicKey,
|
|
287
|
+
lenOrView(publicKey),
|
|
288
|
+
BigInt(nowSeconds),
|
|
289
|
+
)
|
|
290
|
+
},
|
|
291
|
+
urlBuilderResolve(inner, reference) {
|
|
292
|
+
// Opaque-handle resolve against a `UrlBuilder`'s PRECOMPILED base. 0 =
|
|
293
|
+
// null handle / non-UTF-8 reference (real error → growExact throws);
|
|
294
|
+
// w > output.length = exact needed size (one exact retry).
|
|
295
|
+
return growExact(
|
|
296
|
+
(out) =>
|
|
297
|
+
Number(urlBuilderResolveRaw(inner, reference, lenOrView(reference), out, lenOrView(out))),
|
|
298
|
+
Math.min(reference.length * 2 + 128, 64 * 1024),
|
|
299
|
+
1024 * 1024,
|
|
300
|
+
'url builder resolve: invalid reference or output buffer too small',
|
|
301
|
+
)
|
|
302
|
+
},
|
|
303
|
+
ingressHandlePacked(inner, input, body, output) {
|
|
304
|
+
const w = Number(
|
|
305
|
+
ingressHandlePacked(
|
|
306
|
+
inner,
|
|
307
|
+
input,
|
|
308
|
+
lenOrView(input),
|
|
309
|
+
body ?? EMPTY_VIEW,
|
|
310
|
+
// Under `buffer_length` the length slot must be a view (the engine
|
|
311
|
+
// reads byteLength off it) — `EMPTY_VIEW` has byteLength 0, matching
|
|
312
|
+
// a null body; under `(ptr,usize)` it's the explicit length 0.
|
|
313
|
+
body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
|
|
314
|
+
output,
|
|
315
|
+
lenOrView(output),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
if (w === 0) {
|
|
319
|
+
throw new Error('ingress handle: output buffer too small or pipeline error')
|
|
320
|
+
}
|
|
321
|
+
return w
|
|
322
|
+
},
|
|
323
|
+
ingressHandleComponents(inner, methodKind, url, ip, rid, headers, body, output) {
|
|
324
|
+
// `url`/`ip` are passed as JS strings to `cstring` args — the engine
|
|
325
|
+
// transcodes them to call-scoped NUL-terminated UTF-8 buffers in-engine
|
|
326
|
+
// (no JS-side `Buffer.write` encode, no frame assembly for URL/IP).
|
|
327
|
+
const w = Number(
|
|
328
|
+
ingressHandleComponentsSym(
|
|
329
|
+
inner,
|
|
330
|
+
methodKind,
|
|
331
|
+
url,
|
|
332
|
+
ip,
|
|
333
|
+
rid,
|
|
334
|
+
lenOrView(rid),
|
|
335
|
+
headers,
|
|
336
|
+
lenOrView(headers),
|
|
337
|
+
body ?? EMPTY_VIEW,
|
|
338
|
+
body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
|
|
339
|
+
output,
|
|
340
|
+
lenOrView(output),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
if (w === 0) {
|
|
344
|
+
throw new Error('ingress components: output buffer too small or pipeline error')
|
|
345
|
+
}
|
|
346
|
+
return w
|
|
347
|
+
},
|
|
348
|
+
ingressLayout(out) {
|
|
349
|
+
const w = Number(ingressLayoutSym(out, lenOrView(out)))
|
|
350
|
+
if (w !== out.length) {
|
|
351
|
+
throw new Error('ingress layout: output buffer too small')
|
|
352
|
+
}
|
|
353
|
+
return w
|
|
354
|
+
},
|
|
355
|
+
routeCompile(descriptor) {
|
|
356
|
+
const handle = Number(routeCompileSym(descriptor, lenOrView(descriptor)))
|
|
357
|
+
if (handle === 0) {
|
|
358
|
+
throw new Error('route compile: invalid route descriptor')
|
|
359
|
+
}
|
|
360
|
+
return handle
|
|
361
|
+
},
|
|
362
|
+
routeRun(handle, frame, output) {
|
|
363
|
+
// Needed-size convention: `0` = real error (malformed frame / panic); a
|
|
364
|
+
// write larger than `output.length` is the EXACT required size (caller
|
|
365
|
+
// allocates once and retries) — only a `0` write throws here.
|
|
366
|
+
const w = Number(routeRunSym(handle, frame, lenOrView(frame), output, lenOrView(output)))
|
|
367
|
+
if (w === 0) {
|
|
368
|
+
throw new Error('route run: malformed frame or pipeline error')
|
|
369
|
+
}
|
|
370
|
+
return w
|
|
371
|
+
},
|
|
372
|
+
routeDestroy(handle) {
|
|
373
|
+
routeDestroySym(handle)
|
|
374
|
+
},
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Bind-time self-test for the opaque-handle surface (the methods built in
|
|
380
|
+
* `buildInstances`). `false` disables the ffi layer and forces the napi
|
|
381
|
+
* fallback.
|
|
382
|
+
*/
|
|
383
|
+
export function selfTestInstances(b: BunFFI): boolean {
|
|
384
|
+
const enc = { encode: encodeUtf8 }
|
|
385
|
+
const dec = { decode: decodeUtf8 }
|
|
386
|
+
|
|
387
|
+
// Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
|
|
388
|
+
// `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
|
|
389
|
+
// Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
|
|
390
|
+
// field against output.rs. Slot order mirrors the struct field order.
|
|
391
|
+
const layoutBuf = new Uint8Array(38 * 4)
|
|
392
|
+
b.ingressLayout(layoutBuf)
|
|
393
|
+
const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
|
|
394
|
+
if (
|
|
395
|
+
layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
|
|
396
|
+
layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
|
|
397
|
+
layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
|
|
398
|
+
layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
|
|
399
|
+
layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
|
|
400
|
+
layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
|
|
401
|
+
) {
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
|
|
406
|
+
// 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
|
|
407
|
+
// count/types/return) at bind time — a signature drift would surface here
|
|
408
|
+
// instead of crashing under load. Real frame→output parity is covered by
|
|
409
|
+
// ffi.test.ts against a live napi instance.
|
|
410
|
+
try {
|
|
411
|
+
b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
|
|
412
|
+
return false // a null handle must throw, not return
|
|
413
|
+
} catch {
|
|
414
|
+
// expected: null inner handle → 0 → throw
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Ingress raw-components C-ABI: with a null (0) inner handle the Rust side
|
|
418
|
+
// returns 0 immediately and the wrapper throws — exercises the 12-arg ABI
|
|
419
|
+
// (incl. the two `cstring` slots for url/ip) at bind time. Real frame→output
|
|
420
|
+
// parity is covered by ffi.test.ts against a live napi instance.
|
|
421
|
+
try {
|
|
422
|
+
b.ingressHandleComponents(
|
|
423
|
+
0,
|
|
424
|
+
0, // GET
|
|
425
|
+
'/',
|
|
426
|
+
'',
|
|
427
|
+
enc.encode('rid'),
|
|
428
|
+
new Uint8Array(2), // empty packed headers [u16 0]
|
|
429
|
+
null,
|
|
430
|
+
new Uint8Array(64),
|
|
431
|
+
)
|
|
432
|
+
return false // a null handle must throw, not return
|
|
433
|
+
} catch {
|
|
434
|
+
// expected: null inner handle → 0 → throw
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Per-route native stack (castrum_route_*): compile a parseQuery-only
|
|
438
|
+
// descriptor, run one frame, assert the packed verdict. Exercises all three
|
|
439
|
+
// symbols' ABI at bind time (handle return, run needed-size, destroy).
|
|
440
|
+
const routeDesc = new Uint8Array(33)
|
|
441
|
+
const rd = new DataView(routeDesc.buffer)
|
|
442
|
+
rd.setUint32(0, 0x524f5554, true) // ROUTE_DESC_MAGIC "ROUT"
|
|
443
|
+
rd.setUint32(4, 3, true) // ROUTE_DESC_VERSION
|
|
444
|
+
rd.setUint32(8, 2 * 1024 * 1024, true) // maxBodyBytes
|
|
445
|
+
rd.setUint32(12, 8192, true) // maxQueryBytes
|
|
446
|
+
rd.setUint32(16, 8192, true) // maxCookieBytes
|
|
447
|
+
rd.setUint32(20, 0, true) // maxPairs
|
|
448
|
+
rd.setUint32(24, 1, true) // stageCount
|
|
449
|
+
routeDesc[28] = 0 // parseQuery
|
|
450
|
+
rd.setUint32(29, 0, true) // schemaCount
|
|
451
|
+
const routeHandle = b.routeCompile(routeDesc)
|
|
452
|
+
if (routeHandle === 0) {
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
const routeFrame = new Uint8Array(15)
|
|
456
|
+
const rf = new DataView(routeFrame.buffer)
|
|
457
|
+
rf.setUint32(0, 0, true) // flags (no body)
|
|
458
|
+
rf.setUint32(4, 3, true) // qLen
|
|
459
|
+
routeFrame.set(enc.encode('a=1'), 8)
|
|
460
|
+
rf.setUint32(11, 0, true) // cLen
|
|
461
|
+
const routeOut = new Uint8Array(64)
|
|
462
|
+
const routeW = b.routeRun(routeHandle, routeFrame, routeOut)
|
|
463
|
+
if (routeW <= 8) {
|
|
464
|
+
return false // header + a query pair section must exceed 8 bytes
|
|
465
|
+
}
|
|
466
|
+
const rv = new DataView(routeOut.buffer)
|
|
467
|
+
const rFlags = rv.getUint32(0, true)
|
|
468
|
+
if ((rFlags & 0b1) === 0 || (rFlags & 0b100) === 0) {
|
|
469
|
+
return false // OK + QUERY_VALID bits must be set
|
|
470
|
+
}
|
|
471
|
+
b.routeDestroy(routeHandle)
|
|
472
|
+
|
|
473
|
+
// ConditionalRequest opaque-handle: a null (0) handle must return false (the
|
|
474
|
+
// C side never dereferences freed state) — exercises the symbol's ABI. Real
|
|
475
|
+
// verdict parity is covered by test/unit/features/etag.test.ts.
|
|
476
|
+
if (b.conditionalIsNotModified(0, null, null) !== false) {
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Phase 6 opaque-handle instances: null (0) handles must fail SAFELY (false /
|
|
481
|
+
// null / throw — never dereference freed state). Real verdict parity is
|
|
482
|
+
// covered by the per-instance JS tests.
|
|
483
|
+
if (b.mediaTypeMatcherMatches(0, enc.encode('x')) !== false) return false
|
|
484
|
+
if (b.acceptNegotiatorNegotiate(0, enc.encode('gzip')) !== null) return false
|
|
485
|
+
if (b.acceptNegotiatorNegotiateServer(0, enc.encode('gzip')) !== null) return false
|
|
486
|
+
if (b.schemaValidatorValidate(0, enc.encode('{}')) !== false) return false
|
|
487
|
+
if (b.jwtSignerVerify(0, enc.encode('a.b.c'), 0) !== null) return false
|
|
488
|
+
let phase6Threw = false
|
|
489
|
+
try {
|
|
490
|
+
b.jwtSignerSign(0, enc.encode('{}'), 0)
|
|
491
|
+
} catch {
|
|
492
|
+
phase6Threw = true
|
|
493
|
+
}
|
|
494
|
+
if (!phase6Threw) return false
|
|
495
|
+
phase6Threw = false
|
|
496
|
+
try {
|
|
497
|
+
b.templateRender(0, enc.encode('{}'))
|
|
498
|
+
} catch {
|
|
499
|
+
phase6Threw = true
|
|
500
|
+
}
|
|
501
|
+
if (!phase6Threw) return false
|
|
502
|
+
phase6Threw = false
|
|
503
|
+
try {
|
|
504
|
+
b.rateLimiterCheck(0, 'k', 0)
|
|
505
|
+
} catch {
|
|
506
|
+
phase6Threw = true
|
|
507
|
+
}
|
|
508
|
+
if (!phase6Threw) return false
|
|
509
|
+
|
|
510
|
+
// JWT sign pooled Into: same token as the cstring path.
|
|
511
|
+
const jwtClaims = enc.encode('{"sub":"user-1"}')
|
|
512
|
+
const jwtSecret2 = enc.encode('my-secret')
|
|
513
|
+
const jwtOut = new Uint8Array(512)
|
|
514
|
+
const jwtW = b.jwtSignBytesInto(jwtClaims, jwtSecret2, 0, 0, jwtOut)
|
|
515
|
+
if (jwtW === 0 || dec.decode(jwtOut.subarray(0, jwtW)).split('.').length !== 3) {
|
|
516
|
+
return false
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
|
|
520
|
+
// signature with the FFI HMAC to prove the binding is real.
|
|
521
|
+
const jwtSecret = enc.encode('my-secret')
|
|
522
|
+
const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
|
|
523
|
+
const jwtStr = jwt
|
|
524
|
+
const segs = jwtStr.split('.')
|
|
525
|
+
if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
|
|
526
|
+
return false
|
|
527
|
+
}
|
|
528
|
+
const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
|
|
529
|
+
const sigHex = b.hmacSha256(jwtSecret, signingInput)
|
|
530
|
+
const sigBytes = b.base64Decode(enc.encode(segs[2] ?? ''), true, false)
|
|
531
|
+
if (sigBytes === null || b.hexEncode(sigBytes) !== sigHex) {
|
|
532
|
+
return false
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// jwtVerify: sign (ttl=0 → no iat/exp) then verify → claims JSON; tampered → null.
|
|
536
|
+
const vjwtSecret = enc.encode('verify-secret')
|
|
537
|
+
const vjwt = b.jwtSignBytes(enc.encode('{"sub":"u-1"}'), vjwtSecret, 0, 0)
|
|
538
|
+
const vjwtClaims = b.jwtVerify(enc.encode(vjwt), vjwtSecret, 0)
|
|
539
|
+
if (vjwtClaims === null || !vjwtClaims.includes('"sub":"u-1"')) {
|
|
540
|
+
return false
|
|
541
|
+
}
|
|
542
|
+
if (b.jwtVerify(enc.encode('tampered.token.value'), vjwtSecret, 0) !== null) {
|
|
543
|
+
return false
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Ed25519 round-trip: generate → sign → verify (and a tampered signature
|
|
547
|
+
// must be rejected). The generate wrapper also parses the packed blob, so
|
|
548
|
+
// this pins the `[u32 privLen][priv][u32 pubLen][pub]` layout.
|
|
549
|
+
const edKp = b.ed25519GenerateKeypair()
|
|
550
|
+
const edMsg = enc.encode('ed25519 bind-time self-test')
|
|
551
|
+
const edSig = b.ed25519Sign(edMsg, edKp.privateKey)
|
|
552
|
+
if (edSig.byteLength !== 64 || !b.ed25519Verify(edMsg, edSig, edKp.publicKey)) {
|
|
553
|
+
return false
|
|
554
|
+
}
|
|
555
|
+
const edTampered = edSig.slice()
|
|
556
|
+
edTampered[0] = (edTampered[0] ?? 0) ^ 0xff
|
|
557
|
+
if (b.ed25519Verify(edMsg, edTampered, edKp.publicKey)) {
|
|
558
|
+
return false
|
|
559
|
+
}
|
|
560
|
+
// EdDSA JWT round-trip on the SAME keypair (ttl=0 → no iat/exp, so verify
|
|
561
|
+
// succeeds at any `now`).
|
|
562
|
+
const edToken = b.jwtEdDSASign(enc.encode('{"sub":"ffi-self-test"}'), edKp.privateKey, 0, 0)
|
|
563
|
+
if (edToken === null || edToken.split('.').length !== 3) {
|
|
564
|
+
return false
|
|
565
|
+
}
|
|
566
|
+
const edClaims = b.jwtEdDSAVerify(enc.encode(edToken), edKp.publicKey, 0)
|
|
567
|
+
if (edClaims === null || !edClaims.includes('"sub":"ffi-self-test"')) {
|
|
568
|
+
return false
|
|
569
|
+
}
|
|
570
|
+
if (b.jwtEdDSAVerify(enc.encode('tampered.token.value'), edKp.publicKey, 0) !== null) {
|
|
571
|
+
return false
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// UrlBuilder opaque-handle: a null (0) handle must throw (the C side never
|
|
575
|
+
// dereferences freed state) — exercises the symbol's ABI. Real resolve
|
|
576
|
+
// parity is covered by test/unit/features/url-join.test.ts.
|
|
577
|
+
let urlBuilderThrew = false
|
|
578
|
+
try {
|
|
579
|
+
b.urlBuilderResolve(0, enc.encode('g'))
|
|
580
|
+
} catch {
|
|
581
|
+
urlBuilderThrew = true
|
|
582
|
+
}
|
|
583
|
+
if (!urlBuilderThrew) {
|
|
584
|
+
return false
|
|
585
|
+
}
|
|
586
|
+
// rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
|
|
587
|
+
let rlKeyThrew = false
|
|
588
|
+
try {
|
|
589
|
+
b.rateLimiterCheckKey(0, 12345, 0)
|
|
590
|
+
} catch {
|
|
591
|
+
rlKeyThrew = true
|
|
592
|
+
}
|
|
593
|
+
if (!rlKeyThrew) {
|
|
594
|
+
return false
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return true
|
|
598
|
+
}
|