castrum 0.9.0 → 0.9.1
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 +1374 -1772
- package/dist/index.js +5570 -4169
- package/docs/API.md +337 -0
- package/docs/ARCHITECTURE.md +106 -14
- package/docs/BENCHMARKS.md +83 -47
- package/docs/CASE_STUDY.md +240 -0
- package/docs/ENVIRONMENT.md +8 -6
- package/docs/FFI_BUN_GUIDE.md +327 -0
- package/docs/GETTING_STARTED.md +15 -32
- package/docs/INGRESS-ROUTER.md +81 -0
- package/docs/INGRESS.md +5 -4
- package/docs/NATIVE-ROUTE.md +53 -0
- package/docs/REPO_MAP.md +46 -26
- package/docs/adr/0003-bun-builtins-delegation.md +6 -5
- package/docs/bun-builtins-decision-matrix.md +25 -18
- package/index.ts +9 -16
- package/package.json +36 -24
- package/src/ingress/body.ts +167 -27
- package/src/ingress/handlers.ts +91 -15
- package/src/ingress/health.ts +1 -0
- package/src/ingress/index.ts +39 -27
- package/src/ingress/packing/gather-raw-headers.ts +85 -21
- package/src/ingress/packing/header-packing.ts +5 -5
- package/src/ingress/packing/input-packer.ts +17 -3
- package/src/ingress/packing/select-headers.ts +12 -6
- package/src/ingress/router.ts +237 -0
- package/src/ingress/routes/common.ts +42 -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 +7 -31
- 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 +6 -6
- package/src/ingress/server.ts +45 -13
- package/src/ingress/types.ts +35 -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/constants.ts +58 -0
- package/src/native/ffi/selftest.ts +729 -0
- package/src/native/ffi/types.ts +474 -0
- package/src/native/ffi.ts +1063 -829
- package/src/native/loader.ts +52 -6
- package/src/native/types/instances.ts +274 -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 +2 -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 +253 -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
package/src/native/ffi.ts
CHANGED
|
@@ -1,271 +1,82 @@
|
|
|
1
|
-
// src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi
|
|
1
|
+
// src/native/ffi.ts — Bun-only C-ABI fast path via `bun:ffi` (transport core).
|
|
2
2
|
//
|
|
3
3
|
// Bun JIT-compiles `bun:ffi` calls down to direct native calls (~10-20ns
|
|
4
4
|
// crossing), versus ~100-350ns for a Node-API call. The same cdylib that Node
|
|
5
5
|
// loads through napi-rs ALSO exports `extern "C"` symbols (`rust/ffi.rs`), so
|
|
6
|
-
// under Bun we can `dlopen` it and call the hot scalar functions directly
|
|
7
|
-
//
|
|
6
|
+
// under Bun we can `dlopen` it and call the hot scalar functions directly.
|
|
7
|
+
//
|
|
8
|
+
// Structure: this file is the transport CORE (dlopen + symbol binding +
|
|
9
|
+
// per-call wrappers). The pure pieces live beside it: `ffi/types.ts` (BunFFI /
|
|
10
|
+
// FfiMode / Raw signatures), `ffi/constants.ts` (caps + sizing + self-test
|
|
11
|
+
// vectors), `ffi/selftest.ts` (the bind-time self-test). This file re-exports
|
|
12
|
+
// `getBunFFI` + `BunFFI` so existing importers keep working unchanged.
|
|
8
13
|
//
|
|
9
14
|
// Safety / correctness strategy:
|
|
10
15
|
// - Lazily bound (no addon/ffi work until first use).
|
|
11
|
-
// - A one-time SELF-TEST runs at bind time
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// - Decoders that can fail (hexDecode / urlDecode / base64Decode) ARE exposed
|
|
17
|
-
// here with parity error semantics: a `0` write on non-empty input throws
|
|
18
|
-
// (the napi decoders throw rather than return a short buffer). The only
|
|
19
|
-
// unavoidable divergence is decompressing a stream that yields exactly 0
|
|
20
|
-
// bytes, which the C ABI can't distinguish from "too small" (napi returns
|
|
21
|
-
// an empty buffer; ffi throws) — degenerate and not exercised by tests.
|
|
16
|
+
// - A one-time SELF-TEST runs at bind time (ffi/selftest.ts); any failure
|
|
17
|
+
// disables ffi and falls back to the napi addon.
|
|
18
|
+
// - Decoders that can fail ARE exposed with parity error semantics: a `0`
|
|
19
|
+
// write on non-empty input throws (the napi decoders throw rather than
|
|
20
|
+
// return a short buffer).
|
|
22
21
|
// - Never called under Node (falls back to napi immediately).
|
|
23
22
|
//
|
|
24
23
|
// Buffer ABI: input/out pointers are `(ptr, len)` pairs; the JS wrapper passes
|
|
25
24
|
// `(view, view.length)` and bun:ffi converts the TypedArray to its pointer.
|
|
26
25
|
|
|
27
|
-
// Type-only: erased at compile time (no runtime import of `bun:ffi` — that
|
|
28
|
-
// stays lazy inside `bind()`). `FFITypeOrString` is the dlopen ABI-string union.
|
|
29
26
|
import type { FFITypeOrString } from 'bun:ffi'
|
|
27
|
+
import { decodeUtf8, encodeUtf8 } from '../shared/codec'
|
|
30
28
|
import { resolveEnvVar } from '../shared/env'
|
|
31
29
|
import { isBun } from '../shared/runtime'
|
|
30
|
+
import {
|
|
31
|
+
COMPRESS_HEADROOM,
|
|
32
|
+
COMPRESS_INITIAL_CAP,
|
|
33
|
+
COMPRESS_MAX_CAP,
|
|
34
|
+
DECOMPRESS_FALLBACK_CAP,
|
|
35
|
+
DECOMPRESS_GUESS_MULTIPLIER_BROTLI,
|
|
36
|
+
DECOMPRESS_GUESS_MULTIPLIER_GZIP,
|
|
37
|
+
DECOMPRESS_MIN_INITIAL,
|
|
38
|
+
EMPTY_VIEW,
|
|
39
|
+
MAX_DECOMPRESSED,
|
|
40
|
+
MAX_JSON_PATCH_OUTPUT,
|
|
41
|
+
} from './ffi/constants'
|
|
42
|
+
import { selfTest } from './ffi/selftest'
|
|
43
|
+
import type {
|
|
44
|
+
BunFFI,
|
|
45
|
+
FfiMode,
|
|
46
|
+
Raw2,
|
|
47
|
+
Raw3,
|
|
48
|
+
Raw4,
|
|
49
|
+
Raw5,
|
|
50
|
+
Raw6,
|
|
51
|
+
Raw7,
|
|
52
|
+
Raw8,
|
|
53
|
+
Raw9,
|
|
54
|
+
Raw10,
|
|
55
|
+
Raw12,
|
|
56
|
+
RawCStr,
|
|
57
|
+
} from './ffi/types'
|
|
32
58
|
// Static import: loader.ts has no side effects (path resolution only — no
|
|
33
59
|
// dlopen), so this adds zero import-time cost while letting `bind()` reuse the
|
|
34
60
|
// exact same resolved `.node` path the napi fallback uses (the shared seam).
|
|
35
61
|
import { getAddonPath } from './loader'
|
|
36
62
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
export interface BunFFI {
|
|
41
|
-
crc32(input: Uint8Array): number
|
|
42
|
-
fnv1a64(input: Uint8Array): bigint
|
|
43
|
-
xxh3(input: Uint8Array): bigint
|
|
44
|
-
jsonValid(input: Uint8Array): boolean
|
|
45
|
-
/** Lowercase-hex encode into a fresh `Uint8Array` (size `input.length * 2`). */
|
|
46
|
-
hexEncode(input: Uint8Array): Uint8Array
|
|
47
|
-
/** Lowercase-hex encode into `output`; returns bytes written. */
|
|
48
|
-
hexEncodeInto(input: Uint8Array, output: Uint8Array): number
|
|
49
|
-
/** RFC 3986 percent-encode into a fresh buffer (size `input.length * 3`). */
|
|
50
|
-
urlEncode(input: Uint8Array): Uint8Array
|
|
51
|
-
/** RFC 3986 percent-encode into `output`; returns bytes written. */
|
|
52
|
-
urlEncodeInto(input: Uint8Array, output: Uint8Array): number
|
|
53
|
-
|
|
54
|
-
// ── Validators (u8 → boolean) ─────────────────────────────────────
|
|
55
|
-
validateEmail(input: Uint8Array): boolean
|
|
56
|
-
validateUuid(input: Uint8Array): boolean
|
|
57
|
-
validateIpv4(input: Uint8Array): boolean
|
|
58
|
-
validateIpv6(input: Uint8Array): boolean
|
|
59
|
-
/** Sum of `id` fields across a JSON array → bigint (throws on non-array input). */
|
|
60
|
-
jsonSumIds(input: Uint8Array): bigint
|
|
61
|
-
|
|
62
|
-
// ── Constant-time verify (u8 → boolean) ───────────────────────────
|
|
63
|
-
hmacSha256Verify(key: Uint8Array, data: Uint8Array, signature: Uint8Array): boolean
|
|
64
|
-
csrfVerify(token: Uint8Array, secret: Uint8Array): boolean
|
|
65
|
-
passwordVerify(password: Uint8Array, phc: Uint8Array): boolean
|
|
66
|
-
passwordVerifyBcrypt(password: Uint8Array, phc: Uint8Array): boolean
|
|
67
|
-
|
|
68
|
-
// ── Decoders (throw on malformed input — napi `Result` parity) ──
|
|
69
|
-
/** Hex-decode into a fresh buffer (size `input.length / 2`). */
|
|
70
|
-
hexDecode(input: Uint8Array): Uint8Array
|
|
71
|
-
/** Hex-decode into `output`; returns bytes written (throws if too small). */
|
|
72
|
-
hexDecodeInto(input: Uint8Array, output: Uint8Array): number
|
|
73
|
-
/** Percent-decode into a fresh buffer (size `input.length`). */
|
|
74
|
-
urlDecode(input: Uint8Array): Uint8Array
|
|
75
|
-
/** Percent-decode into `output`; returns bytes written (throws if too small). */
|
|
76
|
-
urlDecodeInto(input: Uint8Array, output: Uint8Array): number
|
|
77
|
-
/** base64-decode into a fresh buffer (size `ceil(len/4)*3`). */
|
|
78
|
-
base64Decode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array
|
|
79
|
-
/** base64-decode into `output`; returns bytes written (throws if too small). */
|
|
80
|
-
base64DecodeInto(
|
|
81
|
-
input: Uint8Array,
|
|
82
|
-
output: Uint8Array,
|
|
83
|
-
urlSafe?: boolean,
|
|
84
|
-
padding?: boolean,
|
|
85
|
-
): number
|
|
86
|
-
|
|
87
|
-
// ── Fixed-size output writers ─────────────────────────────────────
|
|
88
|
-
/** RFC 6455 Sec-WebSocket-Accept (28 bytes) into a fresh buffer. */
|
|
89
|
-
wsAcceptKey(key: Uint8Array): Uint8Array
|
|
90
|
-
/** crc32 ETag (10 strong / 12 weak bytes) into a fresh buffer. */
|
|
91
|
-
etag(data: Uint8Array, weak?: boolean): Uint8Array
|
|
92
|
-
/** crc32 ETag into `output`; returns bytes written. */
|
|
93
|
-
etagInto(data: Uint8Array, output: Uint8Array, weak?: boolean): number
|
|
94
|
-
/** `byteLen` random bytes → `byteLen * 2` hex chars. */
|
|
95
|
-
randomToken(byteLen: number): Uint8Array
|
|
96
|
-
/** base64-encode into a fresh buffer (size `ceil(len/3)*4`). */
|
|
97
|
-
base64Encode(input: Uint8Array, urlSafe?: boolean, padding?: boolean): Uint8Array
|
|
98
|
-
/** base64-encode into `output`; returns bytes written. */
|
|
99
|
-
base64EncodeInto(
|
|
100
|
-
input: Uint8Array,
|
|
101
|
-
output: Uint8Array,
|
|
102
|
-
urlSafe?: boolean,
|
|
103
|
-
padding?: boolean,
|
|
104
|
-
): number
|
|
105
|
-
/** HMAC-SHA256 hex (64 chars) into a fresh buffer. */
|
|
106
|
-
hmacSha256(key: Uint8Array, data: Uint8Array): Uint8Array
|
|
107
|
-
/** HMAC-SHA256 hex into `output`; returns bytes written (throws if too small). */
|
|
108
|
-
hmacSha256Into(key: Uint8Array, data: Uint8Array, output: Uint8Array): number
|
|
109
|
-
/** Sign cookie `value` as `value.<64-hex>` into a fresh buffer. */
|
|
110
|
-
signCookie(value: Uint8Array, secret: Uint8Array): Uint8Array
|
|
111
|
-
/** Sign cookie into `output`; returns bytes written (throws if too small). */
|
|
112
|
-
signCookieInto(value: Uint8Array, secret: Uint8Array, output: Uint8Array): number
|
|
113
|
-
/** Verify a signed cookie → value bytes, or `null` on bad signature. */
|
|
114
|
-
verifyCookie(signed: Uint8Array, secret: Uint8Array): Uint8Array | null
|
|
115
|
-
/** CSRF token (129 bytes: 64 rnd-hex + '.' + 64 sig-hex). */
|
|
116
|
-
csrfToken(secret: Uint8Array): Uint8Array
|
|
117
|
-
/** Argon2id PHC hash bytes (m/t/p/out_len params). */
|
|
118
|
-
passwordHash(
|
|
119
|
-
password: Uint8Array,
|
|
120
|
-
salt: Uint8Array,
|
|
121
|
-
mCost: number,
|
|
122
|
-
tCost: number,
|
|
123
|
-
pCost: number,
|
|
124
|
-
outLen: number,
|
|
125
|
-
): Uint8Array
|
|
126
|
-
/** bcrypt `$2b$` PHC string bytes (cost clamped 4..=31). */
|
|
127
|
-
passwordHashBcrypt(password: Uint8Array, cost: number): Uint8Array
|
|
128
|
-
/** PBKDF2-HMAC-SHA256 → `dkLen` bytes (rounds, dkLen clamped). */
|
|
129
|
-
pbkdf2Sha256(password: Uint8Array, salt: Uint8Array, rounds: number, dkLen: number): Uint8Array
|
|
130
|
-
/** AEAD encrypt (0 = AES-256-GCM, 1 = ChaCha20-Poly1305) → ct+tag. */
|
|
131
|
-
aeadEncrypt(
|
|
132
|
-
key: Uint8Array,
|
|
133
|
-
nonce: Uint8Array,
|
|
134
|
-
plaintext: Uint8Array,
|
|
135
|
-
algorithm?: number,
|
|
136
|
-
): Uint8Array
|
|
137
|
-
/** AEAD encrypt into `output` (ct + 16-byte tag); returns bytes written. */
|
|
138
|
-
aeadEncryptInto(
|
|
139
|
-
key: Uint8Array,
|
|
140
|
-
nonce: Uint8Array,
|
|
141
|
-
plaintext: Uint8Array,
|
|
142
|
-
output: Uint8Array,
|
|
143
|
-
algorithm?: number,
|
|
144
|
-
): number
|
|
145
|
-
/** AEAD decrypt → plaintext, or `null` on auth failure. */
|
|
146
|
-
aeadDecrypt(
|
|
147
|
-
key: Uint8Array,
|
|
148
|
-
nonce: Uint8Array,
|
|
149
|
-
ciphertext: Uint8Array,
|
|
150
|
-
algorithm?: number,
|
|
151
|
-
): Uint8Array | null
|
|
152
|
-
|
|
153
|
-
// ── Frame / patch / compression (variable-size → grow-retry) ─────
|
|
154
|
-
/** RFC 6455 frame encode into a fresh buffer. */
|
|
155
|
-
wsFrameEncode(opcode: number, payload: Uint8Array, mask: boolean, fin: boolean): Uint8Array
|
|
156
|
-
/** RFC 6455 frame encode into `output`; returns bytes written (throws if too small). */
|
|
157
|
-
wsFrameEncodeInto(
|
|
158
|
-
opcode: number,
|
|
159
|
-
payload: Uint8Array,
|
|
160
|
-
mask: boolean,
|
|
161
|
-
fin: boolean,
|
|
162
|
-
output: Uint8Array,
|
|
163
|
-
): number
|
|
164
|
-
/** RFC 6902 JSON patch into a fresh buffer. */
|
|
165
|
-
jsonPatch(doc: Uint8Array, patch: Uint8Array): Uint8Array
|
|
166
|
-
/** gzip-compress into a fresh buffer (level clamped 0..=9, default 6). */
|
|
167
|
-
gzipCompress(data: Uint8Array, level?: number): Uint8Array
|
|
168
|
-
/** gzip-compress into `output`; returns bytes written (throws if too small). */
|
|
169
|
-
gzipCompressInto(data: Uint8Array, output: Uint8Array, level?: number): number
|
|
170
|
-
/** gzip-decompress into a fresh buffer (capped by `maxDecompressed`). */
|
|
171
|
-
gzipDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
|
|
172
|
-
/** brotli-compress into a fresh buffer (quality clamped 0..=11, default 5). */
|
|
173
|
-
brotliCompress(data: Uint8Array, quality?: number): Uint8Array
|
|
174
|
-
/** brotli-compress into `output`; returns bytes written (throws if too small). */
|
|
175
|
-
brotliCompressInto(data: Uint8Array, output: Uint8Array, quality?: number): number
|
|
176
|
-
/** brotli-decompress into a fresh buffer (capped by `maxDecompressed`). */
|
|
177
|
-
brotliDecompress(data: Uint8Array, maxDecompressed?: number): Uint8Array
|
|
178
|
-
|
|
179
|
-
// ── Packed parsers (into caller buffers) ─────────────────────────
|
|
180
|
-
/** HTTP request parse → packed output into `output`; returns bytes written. */
|
|
181
|
-
httpParseRequestPackedInto(input: Uint8Array, output: Uint8Array): number
|
|
182
|
-
/** Query string parse → packed output into `output`; returns bytes written. */
|
|
183
|
-
queryParsePackedInto(input: Uint8Array, output: Uint8Array): number
|
|
184
|
-
/** Cookie header parse → packed output into `output`; returns bytes written. */
|
|
185
|
-
cookieParsePackedInto(input: Uint8Array, output: Uint8Array): number
|
|
186
|
-
|
|
187
|
-
// ── Excluded-surface additions (packed / opaque-handle) ─────────
|
|
188
|
-
/** Sign JWT (HS256) from pre-serialized claim JSON (ttl<=0 → no iat/exp). */
|
|
189
|
-
jwtSignBytes(claimsJson: Uint8Array, secret: Uint8Array, ttl: number, now: number): Uint8Array
|
|
190
|
-
/** Decode a WS frame into packed `[flags][opcode][u32 len][payload]`; null on malformed. */
|
|
191
|
-
wsFrameDecodePacked(data: Uint8Array): Uint8Array | null
|
|
192
|
-
/** Parse multipart/form-data into the packed parts layout. */
|
|
193
|
-
multipartParsePacked(body: Uint8Array, boundary: Uint8Array): Uint8Array
|
|
194
|
-
/** Parse x-www-form-urlencoded into packed pairs into `output`. */
|
|
195
|
-
formParsePackedInto(input: Uint8Array, output: Uint8Array): number
|
|
196
|
-
/**
|
|
197
|
-
* Run the ingress pipeline on a packed frame via the opaque inner handle from
|
|
198
|
-
* `Ingress.ingressInnerPtr()` (valid only while the instance is alive — the
|
|
199
|
-
* caller must hold it). Returns bytes written; throws on error / too-small.
|
|
200
|
-
*/
|
|
201
|
-
ingressHandlePacked(
|
|
202
|
-
inner: number,
|
|
203
|
-
input: Uint8Array,
|
|
204
|
-
body: Uint8Array | null,
|
|
205
|
-
output: Uint8Array,
|
|
206
|
-
): number
|
|
207
|
-
/**
|
|
208
|
-
* Write the ingress binary-layout constants (38 × u32 LE — `rust/ffi.rs`
|
|
209
|
-
* `IngressLayout`, numeric source `rust/ingress/output.rs`) into `output`;
|
|
210
|
-
* returns bytes written. Lets `src/ingress/constants.ts` read the layout via
|
|
211
|
-
* bun:ffi on Bun so importing the package does NOT dlopen the napi addon.
|
|
212
|
-
*/
|
|
213
|
-
ingressLayout(out: Uint8Array): number
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ── Caps mirrored from the Rust napi layer ────────────────────────
|
|
217
|
-
// rust/payload/compress.rs DEFAULT_MAX_DECOMPRESSED (64 MiB decompression bomb
|
|
218
|
-
// guard — must stay in sync; do not raise without benchmarking).
|
|
219
|
-
const MAX_DECOMPRESSED = 64 * 1024 * 1024
|
|
220
|
-
// rust/json/json_patch_ops.rs MAX_JSON_PATCH_OUTPUT (128 MiB).
|
|
221
|
-
const MAX_JSON_PATCH_OUTPUT = 128 * 1024 * 1024
|
|
222
|
-
|
|
223
|
-
// ── Output-buffer sizing heuristics (measured) ─────────────────────
|
|
224
|
-
// These are ALLOCATION caps only, not correctness bounds: `growExact` covers
|
|
225
|
-
// any residual miss with at most one exact-size retry (no re-run loop), so a
|
|
226
|
-
// guess that is too small costs one extra native pass, never a wrong result.
|
|
227
|
-
//
|
|
228
|
-
// Compress: a 75 KiB input compresses to <1 KiB, so a 16 KiB initial is
|
|
229
|
-
// plenty and single-pass; the 1 MiB ceiling bounds incompressible data.
|
|
230
|
-
const COMPRESS_INITIAL_CAP = 16 * 1024
|
|
231
|
-
const COMPRESS_MAX_CAP = 1024 * 1024
|
|
232
|
-
const COMPRESS_HEADROOM = 64 // small-input slack above `data.length`
|
|
233
|
-
// Decompress (no trailer / expensive trailer): typical JSON/text ratios.
|
|
234
|
-
const DECOMPRESS_GUESS_MULTIPLIER_GZIP = 16
|
|
235
|
-
const DECOMPRESS_GUESS_MULTIPLIER_BROTLI = 32
|
|
236
|
-
const DECOMPRESS_FALLBACK_CAP = 4 * 1024 * 1024 // 4 MiB over-alloc bound
|
|
237
|
-
const DECOMPRESS_MIN_INITIAL = 1024
|
|
238
|
-
// JWT: token ≈ header(~36) + payload(≈4/3× claims) + sig(~43) + 2 dots. The
|
|
239
|
-
// old `claims.length + 128` under-sized a typical token (measured 168 B for a
|
|
240
|
-
// 30 B claim → grow-retry double-run, making ffi slower than napi). 2×+128
|
|
241
|
-
// with a 256-byte floor covers typical claims in one pass.
|
|
242
|
-
const JWT_INITIAL_MULTIPLIER = 2
|
|
243
|
-
const JWT_INITIAL_EXTRA = 128
|
|
244
|
-
const JWT_INITIAL_FLOOR = 256
|
|
245
|
-
|
|
246
|
-
// ── Known-good vectors for the bind-time self-test ───────────────
|
|
247
|
-
// These mirror the Rust `#[cfg(test)]` vectors in rust/ffi.rs.
|
|
248
|
-
const SELFTEST_HEX = encoder.encode('hello') // -> 68656c6c6f
|
|
249
|
-
const SELFTEST_JSON = encoder.encode('{"a":1}')
|
|
250
|
-
|
|
251
|
-
// Empty view for `null` body slots in (ptr, len) pairs — the C side treats
|
|
252
|
-
// len 0 as "no body" regardless of the pointer value.
|
|
253
|
-
const EMPTY_VIEW = new Uint8Array(0)
|
|
63
|
+
// Re-export the type surface so `import type { BunFFI } from '../native/ffi'`
|
|
64
|
+
// keeps working (existing call sites).
|
|
65
|
+
export type { BunFFI, FfiMode } from './ffi/types'
|
|
254
66
|
|
|
255
67
|
let cached: BunFFI | null | undefined
|
|
68
|
+
/**
|
|
69
|
+
* Resolved buffer ABI mode, set once by `bind()`. `null` = ffi unavailable
|
|
70
|
+
* (Node / `CASTRUM_FFI_MODE=napi` / failed self-test), `'buffer-pair'` = the
|
|
71
|
+
* engine-native `buffer`/`buffer_length` pair is live, `'ptr-len'` = the
|
|
72
|
+
* explicit `(ptr, usize)` fallback.
|
|
73
|
+
*/
|
|
74
|
+
let bufferAbiMode: 'buffer-pair' | 'ptr-len' | null = null
|
|
256
75
|
|
|
257
76
|
// ── Transport selection (CASTRUM_FFI_MODE) ───────────────────────
|
|
258
|
-
// auto (default): use bun:ffi on Bun, silently fall back to napi when the
|
|
259
|
-
// bind or self-test fails (and always on Node).
|
|
260
|
-
// ffi : force bun:ffi on Bun — throw a clear error if it can't bind or the
|
|
261
|
-
// self-test fails (use in benches/CI that MUST run ffi).
|
|
262
|
-
// napi : never bind — every call goes through the napi addon (the fallback;
|
|
263
|
-
// useful for exercising the fallback path on Bun).
|
|
264
|
-
// The legacy alias `RUST_FFI_MODE` is accepted too (see src/shared/env.ts).
|
|
265
|
-
export type FfiMode = 'auto' | 'ffi' | 'napi'
|
|
266
77
|
|
|
267
78
|
function resolveFfiMode(): FfiMode {
|
|
268
|
-
const raw = resolveEnvVar('CASTRUM_FFI_MODE'
|
|
79
|
+
const raw = resolveEnvVar('CASTRUM_FFI_MODE')
|
|
269
80
|
switch (raw) {
|
|
270
81
|
case 'ffi':
|
|
271
82
|
return 'ffi'
|
|
@@ -292,6 +103,23 @@ export function getBunFFI(): BunFFI | null {
|
|
|
292
103
|
return cached
|
|
293
104
|
}
|
|
294
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Which buffer ABI the live `bun:ffi` binding uses, or `null` when the
|
|
108
|
+
* transport is unavailable (Node, `CASTRUM_FFI_MODE=napi`, or a failed
|
|
109
|
+
* self-test). `'buffer-pair'` = the engine-native `buffer`/`buffer_length`
|
|
110
|
+
* pair (atomic ptr+byteLength snapshot) is in use; `'ptr-len'` = the explicit
|
|
111
|
+
* `(ptr, usize)` fallback (older Bun / probe failure). Lazy — triggers the
|
|
112
|
+
* same one-time bind as {@link getBunFFI}. Under `CASTRUM_FFI_MODE=ffi` on Bun
|
|
113
|
+
* this is guaranteed non-null; a bind/self-test failure THROWS, matching
|
|
114
|
+
* `getBunFFI`.
|
|
115
|
+
*/
|
|
116
|
+
export function ffiBufferMode(): 'buffer-pair' | 'ptr-len' | null {
|
|
117
|
+
if (cached === undefined) {
|
|
118
|
+
getBunFFI()
|
|
119
|
+
}
|
|
120
|
+
return bufferAbiMode
|
|
121
|
+
}
|
|
122
|
+
|
|
295
123
|
/**
|
|
296
124
|
* Probe whether this Bun build accepts the `buffer`/`buffer_length` ABI pair in
|
|
297
125
|
* `dlopen`. Bun's docs list `buffer_length` as engine-native (dlopen-supported),
|
|
@@ -324,6 +152,7 @@ function probeBufferLength(dlopen: typeof import('bun:ffi')['dlopen'], path: str
|
|
|
324
152
|
}
|
|
325
153
|
|
|
326
154
|
function bind(): BunFFI | null {
|
|
155
|
+
bufferAbiMode = null // reset — the mode below is only valid for a live bind
|
|
327
156
|
const mode = resolveFfiMode()
|
|
328
157
|
if (!isBun()) {
|
|
329
158
|
if (mode === 'ffi') {
|
|
@@ -349,6 +178,7 @@ function bind(): BunFFI | null {
|
|
|
349
178
|
// TypedArray at call time — an atomic snapshot) once; when supported we use
|
|
350
179
|
// it for the input-only hot fns (one JS arg instead of two), else `(ptr,len)`.
|
|
351
180
|
const useBufferLength = probeBufferLength(dlopen, path)
|
|
181
|
+
bufferAbiMode = useBufferLength ? 'buffer-pair' : 'ptr-len'
|
|
352
182
|
|
|
353
183
|
// String ABI specs ("ptr"/"usize"/"u32"...): accepted by bun:ffi and typed
|
|
354
184
|
// via `FFITypeOrString` in bun-types. usize == uint64 on the host ABI.
|
|
@@ -359,115 +189,280 @@ function bind(): BunFFI | null {
|
|
|
359
189
|
const inputAbi: readonly FFITypeOrString[] = useBufferLength
|
|
360
190
|
? (['buffer', 'buffer_length'] as unknown as readonly FFITypeOrString[])
|
|
361
191
|
: ['ptr', 'usize']
|
|
192
|
+
// Phase 1.1: extend `buffer`/`buffer_length` to EVERY `(ptr,len)` pair, not
|
|
193
|
+
// just the input-only fns. The engine reads ptr + byteLength off the SAME
|
|
194
|
+
// view at call time (atomic snapshot), so call sites pass the view twice
|
|
195
|
+
// (see `lenOrView` below). Conversion is POSITIONAL and pair-aware: each
|
|
196
|
+
// `ptr` consumes its following `usize` as a length. Scalar args that happen
|
|
197
|
+
// to be `usize` (the opaque `inner` handle of `castrum_ingress_handle_packed`
|
|
198
|
+
// and the `max` param of gzip/brotli decompress) are passed through
|
|
199
|
+
// unchanged — a blind `usize`→`buffer_length` map would corrupt them.
|
|
200
|
+
const abi = (shape: readonly string[]): readonly FFITypeOrString[] => {
|
|
201
|
+
if (!useBufferLength) return shape as readonly FFITypeOrString[]
|
|
202
|
+
const out: FFITypeOrString[] = []
|
|
203
|
+
for (let i = 0; i < shape.length; i++) {
|
|
204
|
+
const t = shape[i]
|
|
205
|
+
if (t === 'ptr') {
|
|
206
|
+
out.push('buffer' as unknown as FFITypeOrString)
|
|
207
|
+
out.push('buffer_length' as unknown as FFITypeOrString)
|
|
208
|
+
i++ // consume the paired `usize`
|
|
209
|
+
} else {
|
|
210
|
+
out.push(t as FFITypeOrString)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
215
|
+
// `u64_fast` returns a plain `number` when the value fits (< 2^53) instead
|
|
216
|
+
// of a BigInt — bun-types lacks the literal, so bind it via a typed const
|
|
217
|
+
// (same cast pattern as the `buffer_length` probe above). Every bytes-written
|
|
218
|
+
// return fits in a double, so this removes the per-call BigInt boxing on the
|
|
219
|
+
// hot scalar / `*_into` / packed-parser / ingress calls.
|
|
220
|
+
const U64_FAST = 'u64_fast' as unknown as FFITypeOrString
|
|
362
221
|
const { symbols } = dlopen(path, {
|
|
363
222
|
castrum_crc32: { args: inputAbi, returns: 'u32' },
|
|
364
223
|
castrum_fnv1a64: { args: inputAbi, returns: 'u64' },
|
|
365
224
|
castrum_xxh3: { args: inputAbi, returns: 'u64' },
|
|
366
225
|
castrum_json_valid: { args: inputAbi, returns: 'u8' },
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
226
|
+
castrum_utf8_valid: { args: inputAbi, returns: 'u8' },
|
|
227
|
+
castrum_hex_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
228
|
+
castrum_hex_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
229
|
+
castrum_url_encode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
230
|
+
castrum_url_decode: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
231
|
+
// String-input validators: `cstring` ARG (the engine transcodes the JS
|
|
232
|
+
// string in-engine — no JS-side encode; Rust borrows via CStr::from_ptr).
|
|
233
|
+
castrum_validate_email: { args: ['cstring'], returns: 'u8' },
|
|
234
|
+
castrum_validate_uuid: { args: ['cstring'], returns: 'u8' },
|
|
235
|
+
castrum_validate_ipv4: { args: ['cstring'], returns: 'u8' },
|
|
236
|
+
castrum_validate_ipv6: { args: ['cstring'], returns: 'u8' },
|
|
237
|
+
castrum_json_sum_ids: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
376
238
|
castrum_hmac_sha256_verify: {
|
|
377
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
|
|
239
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
240
|
+
returns: 'u8',
|
|
241
|
+
},
|
|
242
|
+
castrum_csrf_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
|
|
243
|
+
castrum_password_verify: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'u8' },
|
|
244
|
+
castrum_password_verify_bcrypt: {
|
|
245
|
+
// `phc` crosses as a `cstring` ARG; `password` stays a `(ptr,len)` pair.
|
|
246
|
+
args: abi(['ptr', 'usize', 'cstring']),
|
|
247
|
+
returns: 'u8',
|
|
248
|
+
},
|
|
249
|
+
castrum_ws_accept_key: { args: ['cstring'], returns: 'cstring' },
|
|
250
|
+
castrum_ws_accept_key_into: {
|
|
251
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
252
|
+
returns: U64_FAST,
|
|
253
|
+
},
|
|
254
|
+
castrum_etag: { args: abi(['ptr', 'usize', 'u8']), returns: 'cstring' },
|
|
255
|
+
castrum_etag_into: { args: abi(['ptr', 'usize', 'u8', 'ptr', 'usize']), returns: U64_FAST },
|
|
256
|
+
castrum_conditional_is_not_modified: {
|
|
257
|
+
args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'u8']),
|
|
258
|
+
returns: 'u8',
|
|
259
|
+
},
|
|
260
|
+
castrum_media_type_matcher_matches: {
|
|
261
|
+
args: abi(['usize', 'ptr', 'usize']),
|
|
378
262
|
returns: 'u8',
|
|
379
263
|
},
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
264
|
+
castrum_accept_negotiator_negotiate: {
|
|
265
|
+
args: abi(['usize', 'ptr', 'usize']),
|
|
266
|
+
returns: 'cstring',
|
|
267
|
+
},
|
|
268
|
+
castrum_jwt_signer_sign: {
|
|
269
|
+
args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
|
|
270
|
+
returns: U64_FAST,
|
|
271
|
+
},
|
|
272
|
+
castrum_jwt_signer_verify: {
|
|
273
|
+
args: abi(['usize', 'ptr', 'usize', 'i64', 'ptr', 'usize']),
|
|
274
|
+
returns: U64_FAST,
|
|
275
|
+
},
|
|
276
|
+
castrum_template_render: {
|
|
277
|
+
args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
278
|
+
returns: U64_FAST,
|
|
279
|
+
},
|
|
280
|
+
castrum_schema_validator_validate: {
|
|
281
|
+
args: abi(['usize', 'ptr', 'usize']),
|
|
282
|
+
returns: 'u8',
|
|
283
|
+
},
|
|
284
|
+
castrum_rate_limiter_check: {
|
|
285
|
+
// `key` crosses as a `cstring` ARG (text, no NUL in the intended use).
|
|
286
|
+
args: abi(['usize', 'cstring', 'i64', 'ptr', 'usize']),
|
|
287
|
+
returns: U64_FAST,
|
|
288
|
+
},
|
|
289
|
+
castrum_rate_limiter_check_key: {
|
|
290
|
+
args: abi(['usize', 'i64', 'i64', 'ptr', 'usize']),
|
|
291
|
+
returns: U64_FAST,
|
|
292
|
+
},
|
|
293
|
+
castrum_random_token: { args: ['u32'], returns: 'cstring' },
|
|
294
|
+
castrum_random_token_into: { args: ['u32', ...abi(['ptr', 'usize'])], returns: U64_FAST },
|
|
386
295
|
castrum_base64_encode: {
|
|
387
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
|
|
388
|
-
returns:
|
|
296
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
|
|
297
|
+
returns: U64_FAST,
|
|
389
298
|
},
|
|
390
299
|
castrum_base64_decode: {
|
|
391
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8'],
|
|
392
|
-
returns:
|
|
300
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'u8', 'u8']),
|
|
301
|
+
returns: U64_FAST,
|
|
393
302
|
},
|
|
394
303
|
castrum_hmac_sha256: {
|
|
395
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
|
|
396
|
-
returns:
|
|
304
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
305
|
+
returns: U64_FAST,
|
|
397
306
|
},
|
|
398
|
-
castrum_sign_cookie: {
|
|
399
|
-
|
|
400
|
-
|
|
307
|
+
castrum_sign_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
|
|
308
|
+
castrum_sign_cookie_into: {
|
|
309
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
310
|
+
returns: U64_FAST,
|
|
401
311
|
},
|
|
402
|
-
castrum_verify_cookie: {
|
|
403
|
-
|
|
404
|
-
|
|
312
|
+
castrum_verify_cookie: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: 'cstring' },
|
|
313
|
+
castrum_verify_cookie_into: {
|
|
314
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
315
|
+
returns: U64_FAST,
|
|
405
316
|
},
|
|
406
|
-
castrum_csrf_token: { args: ['ptr', 'usize'
|
|
317
|
+
castrum_csrf_token: { args: abi(['ptr', 'usize']), returns: 'cstring' },
|
|
318
|
+
castrum_csrf_token_into: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
407
319
|
castrum_password_hash: {
|
|
408
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize'],
|
|
409
|
-
returns:
|
|
320
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'u32', 'u32', 'ptr', 'usize']),
|
|
321
|
+
returns: U64_FAST,
|
|
410
322
|
},
|
|
411
323
|
castrum_password_hash_bcrypt: {
|
|
412
|
-
args: ['ptr', 'usize', 'u32', 'ptr', 'usize'],
|
|
413
|
-
returns:
|
|
324
|
+
args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
|
|
325
|
+
returns: U64_FAST,
|
|
414
326
|
},
|
|
415
327
|
castrum_pbkdf2_sha256: {
|
|
416
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize'],
|
|
417
|
-
returns:
|
|
328
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'u32', 'u32', 'ptr', 'usize']),
|
|
329
|
+
returns: U64_FAST,
|
|
418
330
|
},
|
|
419
331
|
castrum_aead_encrypt: {
|
|
420
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
|
|
421
|
-
returns:
|
|
332
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
|
|
333
|
+
returns: U64_FAST,
|
|
422
334
|
},
|
|
423
335
|
castrum_aead_decrypt: {
|
|
424
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize'],
|
|
425
|
-
returns:
|
|
336
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'ptr', 'usize']),
|
|
337
|
+
returns: U64_FAST,
|
|
426
338
|
},
|
|
427
339
|
castrum_ws_frame_encode: {
|
|
428
|
-
args: ['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize'],
|
|
429
|
-
returns:
|
|
340
|
+
args: abi(['u8', 'ptr', 'usize', 'u8', 'u8', 'ptr', 'usize']),
|
|
341
|
+
returns: U64_FAST,
|
|
430
342
|
},
|
|
431
343
|
castrum_json_patch: {
|
|
432
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
|
|
433
|
-
returns:
|
|
344
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
345
|
+
returns: U64_FAST,
|
|
346
|
+
},
|
|
347
|
+
castrum_gzip_compress: {
|
|
348
|
+
args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
|
|
349
|
+
returns: U64_FAST,
|
|
434
350
|
},
|
|
435
|
-
castrum_gzip_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
|
|
436
351
|
castrum_gzip_decompress: {
|
|
437
|
-
args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
|
|
438
|
-
returns:
|
|
352
|
+
args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
|
|
353
|
+
returns: U64_FAST,
|
|
354
|
+
},
|
|
355
|
+
castrum_brotli_compress: {
|
|
356
|
+
args: abi(['ptr', 'usize', 'u32', 'ptr', 'usize']),
|
|
357
|
+
returns: U64_FAST,
|
|
439
358
|
},
|
|
440
|
-
castrum_brotli_compress: { args: ['ptr', 'usize', 'u32', 'ptr', 'usize'], returns: 'usize' },
|
|
441
359
|
castrum_brotli_decompress: {
|
|
442
|
-
args: ['ptr', 'usize', 'usize', 'ptr', 'usize'],
|
|
443
|
-
returns:
|
|
360
|
+
args: abi(['ptr', 'usize', 'usize', 'ptr', 'usize']),
|
|
361
|
+
returns: U64_FAST,
|
|
444
362
|
},
|
|
445
|
-
castrum_gzip_isize: { args: ['ptr', 'usize'], returns: 'u32' },
|
|
363
|
+
castrum_gzip_isize: { args: abi(['ptr', 'usize']), returns: 'u32' },
|
|
446
364
|
castrum_http_parse_request_packed: {
|
|
447
|
-
args: ['ptr', 'usize', 'ptr', 'usize'],
|
|
448
|
-
returns:
|
|
365
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
366
|
+
returns: U64_FAST,
|
|
367
|
+
},
|
|
368
|
+
castrum_query_parse_packed: {
|
|
369
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
370
|
+
returns: U64_FAST,
|
|
371
|
+
},
|
|
372
|
+
castrum_cookie_parse_packed: {
|
|
373
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
374
|
+
returns: U64_FAST,
|
|
375
|
+
},
|
|
376
|
+
castrum_http_date_into: { args: ['f64', ...abi(['ptr', 'usize'])], returns: U64_FAST },
|
|
377
|
+
castrum_sse_encode_into: {
|
|
378
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize', 'u8', 'u32', 'ptr', 'usize']),
|
|
379
|
+
returns: U64_FAST,
|
|
449
380
|
},
|
|
450
|
-
castrum_query_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
|
|
451
|
-
castrum_cookie_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
|
|
452
381
|
// ── Excluded-surface additions ──
|
|
453
382
|
castrum_jwt_sign_bytes: {
|
|
454
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64'
|
|
455
|
-
returns: '
|
|
383
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64']),
|
|
384
|
+
returns: 'cstring',
|
|
385
|
+
},
|
|
386
|
+
castrum_jwt_sign_bytes_into: {
|
|
387
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64', 'i64', 'ptr', 'usize']),
|
|
388
|
+
returns: U64_FAST,
|
|
456
389
|
},
|
|
457
390
|
castrum_ws_frame_decode_packed: {
|
|
458
|
-
args: ['ptr', 'usize', 'ptr', 'usize'],
|
|
459
|
-
returns:
|
|
391
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
392
|
+
returns: U64_FAST,
|
|
460
393
|
},
|
|
461
394
|
castrum_multipart_parse_packed: {
|
|
462
|
-
args: ['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
|
|
463
|
-
returns:
|
|
395
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
396
|
+
returns: U64_FAST,
|
|
397
|
+
},
|
|
398
|
+
castrum_form_parse_packed: { args: abi(['ptr', 'usize', 'ptr', 'usize']), returns: U64_FAST },
|
|
399
|
+
// ── Phase 3 — stateless napi-only scalars (now FFI) ──────────
|
|
400
|
+
// Structural parse: parses ONCE, emits a packed token stream with a
|
|
401
|
+
// deduped string table so JS assembles the value with NO second parse.
|
|
402
|
+
castrum_json_parse_packed: {
|
|
403
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
404
|
+
returns: U64_FAST,
|
|
405
|
+
},
|
|
406
|
+
castrum_parse_media_type: {
|
|
407
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
408
|
+
returns: U64_FAST,
|
|
409
|
+
},
|
|
410
|
+
castrum_parse_http_date: {
|
|
411
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
412
|
+
returns: U64_FAST,
|
|
413
|
+
},
|
|
414
|
+
castrum_parse_accept_encoding: {
|
|
415
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
416
|
+
returns: U64_FAST,
|
|
417
|
+
},
|
|
418
|
+
castrum_url_encode_query: { args: abi(['ptr', 'usize']), returns: 'cstring' },
|
|
419
|
+
castrum_url_resolve: {
|
|
420
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize']),
|
|
421
|
+
returns: 'cstring',
|
|
422
|
+
},
|
|
423
|
+
castrum_url_builder_resolve: {
|
|
424
|
+
args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
425
|
+
returns: U64_FAST,
|
|
426
|
+
},
|
|
427
|
+
castrum_mime_from_extension: { args: ['cstring'], returns: 'cstring' },
|
|
428
|
+
castrum_jwt_verify: {
|
|
429
|
+
args: abi(['ptr', 'usize', 'ptr', 'usize', 'i64']),
|
|
430
|
+
returns: 'cstring',
|
|
464
431
|
},
|
|
465
|
-
castrum_form_parse_packed: { args: ['ptr', 'usize', 'ptr', 'usize'], returns: 'usize' },
|
|
466
432
|
castrum_ingress_handle_packed: {
|
|
467
|
-
args: ['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize'],
|
|
468
|
-
returns:
|
|
433
|
+
args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
434
|
+
returns: U64_FAST,
|
|
435
|
+
},
|
|
436
|
+
// Raw-components sibling of `castrum_ingress_handle_packed`: `url`/`ip`
|
|
437
|
+
// are `cstring` ARGs (Bun transcodes the JS strings to call-scoped
|
|
438
|
+
// NUL-terminated buffers in-engine — no JS-side Buffer.write encode);
|
|
439
|
+
// rid/headers/body are `(ptr,len)` byte slices; out is `(ptr,len)`.
|
|
440
|
+
castrum_ingress_handle_components: {
|
|
441
|
+
args: abi([
|
|
442
|
+
'usize',
|
|
443
|
+
'u8',
|
|
444
|
+
'cstring',
|
|
445
|
+
'cstring',
|
|
446
|
+
'ptr',
|
|
447
|
+
'usize',
|
|
448
|
+
'ptr',
|
|
449
|
+
'usize',
|
|
450
|
+
'ptr',
|
|
451
|
+
'usize',
|
|
452
|
+
'ptr',
|
|
453
|
+
'usize',
|
|
454
|
+
]),
|
|
455
|
+
returns: U64_FAST,
|
|
469
456
|
},
|
|
470
|
-
castrum_ingress_layout: { args: ['ptr', 'usize'], returns:
|
|
457
|
+
castrum_ingress_layout: { args: abi(['ptr', 'usize']), returns: U64_FAST },
|
|
458
|
+
// Per-route native stack (`@ignex/native` also dlopens these exact names
|
|
459
|
+
// itself; castrum binds them for the bind-time self-test + parity guard).
|
|
460
|
+
castrum_route_compile: { args: abi(['ptr', 'usize']), returns: U64_FAST },
|
|
461
|
+
castrum_route_run: {
|
|
462
|
+
args: abi(['usize', 'ptr', 'usize', 'ptr', 'usize']),
|
|
463
|
+
returns: U64_FAST,
|
|
464
|
+
},
|
|
465
|
+
castrum_route_destroy: { args: ['usize'], returns: 'void' },
|
|
471
466
|
})
|
|
472
467
|
|
|
473
468
|
const bindings = build(symbols as Record<string, (...a: unknown[]) => unknown>, useBufferLength)
|
|
@@ -493,60 +488,6 @@ function bind(): BunFFI | null {
|
|
|
493
488
|
|
|
494
489
|
// Types for the raw dlopen'd functions (bun:ffi converts Uint8Array -> ptr for
|
|
495
490
|
// `ptr` args; `usize`/`u64`/`i64` returns surface as BigInt).
|
|
496
|
-
type Raw2 = (a: unknown, b: unknown) => number | bigint
|
|
497
|
-
type Raw3 = (a: unknown, b: unknown, c: unknown) => number | bigint
|
|
498
|
-
type Raw4 = (a: unknown, b: unknown, c: unknown, d: unknown) => number | bigint
|
|
499
|
-
type Raw5 = (a: unknown, b: unknown, c: unknown, d: unknown, e: unknown) => number | bigint
|
|
500
|
-
type Raw6 = (
|
|
501
|
-
a: unknown,
|
|
502
|
-
b: unknown,
|
|
503
|
-
c: unknown,
|
|
504
|
-
d: unknown,
|
|
505
|
-
e: unknown,
|
|
506
|
-
f: unknown,
|
|
507
|
-
) => number | bigint
|
|
508
|
-
type Raw7 = (
|
|
509
|
-
a: unknown,
|
|
510
|
-
b: unknown,
|
|
511
|
-
c: unknown,
|
|
512
|
-
d: unknown,
|
|
513
|
-
e: unknown,
|
|
514
|
-
f: unknown,
|
|
515
|
-
g: unknown,
|
|
516
|
-
) => number | bigint
|
|
517
|
-
type Raw8 = (
|
|
518
|
-
a: unknown,
|
|
519
|
-
b: unknown,
|
|
520
|
-
c: unknown,
|
|
521
|
-
d: unknown,
|
|
522
|
-
e: unknown,
|
|
523
|
-
f: unknown,
|
|
524
|
-
g: unknown,
|
|
525
|
-
h: unknown,
|
|
526
|
-
) => number | bigint
|
|
527
|
-
type Raw9 = (
|
|
528
|
-
a: unknown,
|
|
529
|
-
b: unknown,
|
|
530
|
-
c: unknown,
|
|
531
|
-
d: unknown,
|
|
532
|
-
e: unknown,
|
|
533
|
-
f: unknown,
|
|
534
|
-
g: unknown,
|
|
535
|
-
h: unknown,
|
|
536
|
-
i: unknown,
|
|
537
|
-
) => number | bigint
|
|
538
|
-
type Raw10 = (
|
|
539
|
-
a: unknown,
|
|
540
|
-
b: unknown,
|
|
541
|
-
c: unknown,
|
|
542
|
-
d: unknown,
|
|
543
|
-
e: unknown,
|
|
544
|
-
f: unknown,
|
|
545
|
-
g: unknown,
|
|
546
|
-
h: unknown,
|
|
547
|
-
i: unknown,
|
|
548
|
-
j: unknown,
|
|
549
|
-
) => number | bigint
|
|
550
491
|
|
|
551
492
|
const flag = (v?: boolean): number => (v ? 1 : 0)
|
|
552
493
|
|
|
@@ -584,17 +525,37 @@ function writeOrThrow(w: number, inputLen: number, label: string): number {
|
|
|
584
525
|
return w
|
|
585
526
|
}
|
|
586
527
|
|
|
528
|
+
/** Unpack the packed rate-limit verdict `[u8 allowed][u32 remaining LE][i64 reset_ms LE]`. */
|
|
529
|
+
function unpackRateCheck(
|
|
530
|
+
out: Uint8Array,
|
|
531
|
+
view: DataView,
|
|
532
|
+
): {
|
|
533
|
+
allowed: boolean
|
|
534
|
+
remaining: number
|
|
535
|
+
resetMs: number
|
|
536
|
+
} {
|
|
537
|
+
return {
|
|
538
|
+
allowed: out[0] === 1,
|
|
539
|
+
remaining: view.getUint32(1, true),
|
|
540
|
+
resetMs: Number(view.getBigInt64(5, true)),
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
587
544
|
/** base64 (no-pad) length of `n` bytes. */
|
|
588
545
|
const b64Len = (n: number): number => (n === 0 ? 0 : Math.ceil(n / 3) * 4 - ((3 - (n % 3)) % 3))
|
|
589
546
|
|
|
590
547
|
/**
|
|
591
|
-
* Exact length of an argon2id PHC string for the given params
|
|
592
|
-
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
548
|
+
* Exact length of an argon2id PHC string for the given params
|
|
549
|
+
* (m/t/p/salt_len/out_len).
|
|
550
|
+
* Format: `$argon2id$v=19$m=<m>,t=<t>,p=<p>$<salt b64>$<hash b64>`.
|
|
551
|
+
* The salt b64 length is base64-no-pad of the CALLER's salt byte length
|
|
552
|
+
* (`SaltString::encode_b64`) — NOT a fixed 16 bytes. Hardcoding 22
|
|
553
|
+
* under-counts salts >16 bytes (e.g. the 18-byte "salty-salt-16bytes" →
|
|
554
|
+
* 24 chars) and growExact would re-run the whole ~tens-of-ms hash on the
|
|
555
|
+
* retry (measured 2× slowdown). Pre-sizing exactly keeps `passwordHash` a
|
|
556
|
+
* single native pass.
|
|
596
557
|
*/
|
|
597
|
-
function argon2PhcLength(m: number, t: number, p: number, outLen: number): number {
|
|
558
|
+
function argon2PhcLength(m: number, t: number, p: number, saltLen: number, outLen: number): number {
|
|
598
559
|
return (
|
|
599
560
|
15 + // "$argon2id$v=19$"
|
|
600
561
|
2 + // "m="
|
|
@@ -604,7 +565,7 @@ function argon2PhcLength(m: number, t: number, p: number, outLen: number): numbe
|
|
|
604
565
|
3 + // ",p="
|
|
605
566
|
String(p).length +
|
|
606
567
|
1 + // "$" before the salt
|
|
607
|
-
|
|
568
|
+
b64Len(saltLen) + // salt b64 (no-pad — depends on the caller's salt length)
|
|
608
569
|
1 + // "$" before the hash
|
|
609
570
|
b64Len(outLen)
|
|
610
571
|
)
|
|
@@ -618,6 +579,7 @@ function build(
|
|
|
618
579
|
const fnv = sym.castrum_fnv1a64 as (...a: unknown[]) => number | bigint
|
|
619
580
|
const xxh = sym.castrum_xxh3 as (...a: unknown[]) => number | bigint
|
|
620
581
|
const jsonValid = sym.castrum_json_valid as (...a: unknown[]) => number | bigint
|
|
582
|
+
const utf8Valid = sym.castrum_utf8_valid as (...a: unknown[]) => number | bigint
|
|
621
583
|
const hexEncode = sym.castrum_hex_encode as Raw4
|
|
622
584
|
const hexDecode = sym.castrum_hex_decode as Raw4
|
|
623
585
|
const urlEncode = sym.castrum_url_encode as Raw4
|
|
@@ -635,19 +597,39 @@ function build(
|
|
|
635
597
|
// JIT folds it away at the call site.
|
|
636
598
|
const oneArg = (raw: (...a: unknown[]) => number | bigint, v: Uint8Array): number | bigint =>
|
|
637
599
|
useBufferLength ? raw(v, v) : raw(v, v.length)
|
|
600
|
+
// Phase 1.1: under `buffer`/`buffer_length` the engine reads ptr + byteLength
|
|
601
|
+
// off the SAME view, so every `(ptr,len)` pair passes the view itself for the
|
|
602
|
+
// length slot (atomic snapshot, one JS arg fewer); under `(ptr,usize)` it's
|
|
603
|
+
// the explicit length. The branch is a bind-time constant → JIT folds it.
|
|
604
|
+
const lenOrView = (v: Uint8Array): Uint8Array | number => (useBufferLength ? v : v.length)
|
|
638
605
|
const hmacVerify = sym.castrum_hmac_sha256_verify as Raw6
|
|
639
606
|
const csrfVerify = sym.castrum_csrf_verify as Raw4
|
|
640
607
|
const passwordVerify = sym.castrum_password_verify as Raw4
|
|
641
|
-
const passwordVerifyBcrypt = sym.castrum_password_verify_bcrypt as
|
|
642
|
-
const wsAcceptKey = sym.castrum_ws_accept_key as
|
|
643
|
-
const
|
|
644
|
-
const
|
|
608
|
+
const passwordVerifyBcrypt = sym.castrum_password_verify_bcrypt as Raw3
|
|
609
|
+
const wsAcceptKey = sym.castrum_ws_accept_key as RawCStr
|
|
610
|
+
const wsAcceptKeyInto = sym.castrum_ws_accept_key_into as Raw4
|
|
611
|
+
const etagCStr = sym.castrum_etag as RawCStr
|
|
612
|
+
const etagIntoRaw = sym.castrum_etag_into as Raw5
|
|
613
|
+
const conditionalIsNotModifiedRaw = sym.castrum_conditional_is_not_modified as Raw6
|
|
614
|
+
const mediaTypeMatcherMatchesRaw = sym.castrum_media_type_matcher_matches as Raw3
|
|
615
|
+
const acceptNegotiatorNegotiateRaw = sym.castrum_accept_negotiator_negotiate as RawCStr
|
|
616
|
+
const jwtSignerSignRaw = sym.castrum_jwt_signer_sign as Raw6
|
|
617
|
+
const jwtSignerVerifyRaw = sym.castrum_jwt_signer_verify as Raw6
|
|
618
|
+
const templateRenderRaw = sym.castrum_template_render as Raw5
|
|
619
|
+
const schemaValidatorValidateRaw = sym.castrum_schema_validator_validate as Raw3
|
|
620
|
+
const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
|
|
621
|
+
const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
|
|
622
|
+
const randomToken = sym.castrum_random_token as RawCStr
|
|
623
|
+
const randomTokenInto = sym.castrum_random_token_into as Raw3
|
|
645
624
|
const base64Encode = sym.castrum_base64_encode as Raw6
|
|
646
625
|
const base64Decode = sym.castrum_base64_decode as Raw6
|
|
647
626
|
const hmacSha256 = sym.castrum_hmac_sha256 as Raw6
|
|
648
|
-
const signCookie = sym.castrum_sign_cookie as
|
|
649
|
-
const
|
|
650
|
-
const
|
|
627
|
+
const signCookie = sym.castrum_sign_cookie as RawCStr
|
|
628
|
+
const signCookieInto = sym.castrum_sign_cookie_into as Raw6
|
|
629
|
+
const verifyCookie = sym.castrum_verify_cookie as RawCStr
|
|
630
|
+
const verifyCookieInto = sym.castrum_verify_cookie_into as Raw6
|
|
631
|
+
const csrfToken = sym.castrum_csrf_token as RawCStr
|
|
632
|
+
const csrfTokenInto = sym.castrum_csrf_token_into as Raw4
|
|
651
633
|
const passwordHash = sym.castrum_password_hash as Raw10
|
|
652
634
|
const passwordHashBcrypt = sym.castrum_password_hash_bcrypt as Raw5
|
|
653
635
|
const pbkdf2 = sym.castrum_pbkdf2_sha256 as Raw8
|
|
@@ -663,12 +645,29 @@ function build(
|
|
|
663
645
|
const httpParsePacked = sym.castrum_http_parse_request_packed as Raw4
|
|
664
646
|
const queryParsePacked = sym.castrum_query_parse_packed as Raw4
|
|
665
647
|
const cookieParsePacked = sym.castrum_cookie_parse_packed as Raw4
|
|
666
|
-
const
|
|
648
|
+
const httpDateIntoRaw = sym.castrum_http_date_into as Raw3
|
|
649
|
+
const sseEncodeIntoRaw = sym.castrum_sse_encode_into as Raw10
|
|
650
|
+
const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
|
|
651
|
+
const jsonParsePackedSym = sym.castrum_json_parse_packed as Raw4
|
|
652
|
+
const jwtSignBytesInto = sym.castrum_jwt_sign_bytes_into as Raw8
|
|
667
653
|
const wsFrameDecodePacked = sym.castrum_ws_frame_decode_packed as Raw4
|
|
668
654
|
const multipartParsePacked = sym.castrum_multipart_parse_packed as Raw6
|
|
669
655
|
const formParsePacked = sym.castrum_form_parse_packed as Raw4
|
|
656
|
+
// Phase 3 — stateless napi-only scalars.
|
|
657
|
+
const parseMediaTypeSym = sym.castrum_parse_media_type as Raw4
|
|
658
|
+
const parseHttpDateSym = sym.castrum_parse_http_date as Raw4
|
|
659
|
+
const parseAcceptEncodingSym = sym.castrum_parse_accept_encoding as Raw4
|
|
660
|
+
const urlEncodeQuerySym = sym.castrum_url_encode_query as (...a: unknown[]) => string | null
|
|
661
|
+
const urlResolveSym = sym.castrum_url_resolve as (...a: unknown[]) => string | null
|
|
662
|
+
const urlBuilderResolveRaw = sym.castrum_url_builder_resolve as Raw5
|
|
663
|
+
const mimeFromExtensionSym = sym.castrum_mime_from_extension as (...a: unknown[]) => string | null
|
|
664
|
+
const jwtVerifySym = sym.castrum_jwt_verify as (...a: unknown[]) => string | null
|
|
670
665
|
const ingressHandlePacked = sym.castrum_ingress_handle_packed as Raw7
|
|
666
|
+
const ingressHandleComponentsSym = sym.castrum_ingress_handle_components as Raw12
|
|
671
667
|
const ingressLayoutSym = sym.castrum_ingress_layout as Raw2
|
|
668
|
+
const routeCompileSym = sym.castrum_route_compile as Raw2
|
|
669
|
+
const routeRunSym = sym.castrum_route_run as Raw5
|
|
670
|
+
const routeDestroySym = sym.castrum_route_destroy as (a: unknown) => void
|
|
672
671
|
|
|
673
672
|
// ── Encoder / decoder `Into` helpers ──────────────────────────────
|
|
674
673
|
const hexEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
|
|
@@ -677,11 +676,11 @@ function build(
|
|
|
677
676
|
if (output.length < input.length * 2) {
|
|
678
677
|
throw new Error('hex encode: output buffer too small')
|
|
679
678
|
}
|
|
680
|
-
return Number(hexEncode(input, input
|
|
679
|
+
return Number(hexEncode(input, lenOrView(input), output, lenOrView(output)))
|
|
681
680
|
}
|
|
682
681
|
|
|
683
682
|
const urlEncodeInto = (input: Uint8Array, output: Uint8Array): number => {
|
|
684
|
-
const w = Number(urlEncode(input, input
|
|
683
|
+
const w = Number(urlEncode(input, lenOrView(input), output, lenOrView(output)))
|
|
685
684
|
// Every input byte encodes to >= 1 output byte, so a 0 write on non-empty
|
|
686
685
|
// input means the buffer was too small (napi throws there too). Empty
|
|
687
686
|
// input legitimately writes 0.
|
|
@@ -692,12 +691,12 @@ function build(
|
|
|
692
691
|
}
|
|
693
692
|
|
|
694
693
|
const hexDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
|
|
695
|
-
const w = Number(hexDecode(input, input
|
|
694
|
+
const w = Number(hexDecode(input, lenOrView(input), output, lenOrView(output)))
|
|
696
695
|
return writeOrThrow(w, input.length, 'hex decode')
|
|
697
696
|
}
|
|
698
697
|
|
|
699
698
|
const urlDecodeInto = (input: Uint8Array, output: Uint8Array): number => {
|
|
700
|
-
const w = Number(urlDecode(input, input
|
|
699
|
+
const w = Number(urlDecode(input, lenOrView(input), output, lenOrView(output)))
|
|
701
700
|
return writeOrThrow(w, input.length, 'url decode')
|
|
702
701
|
}
|
|
703
702
|
|
|
@@ -711,9 +710,9 @@ function build(
|
|
|
711
710
|
const w = Number(
|
|
712
711
|
base64Decode(
|
|
713
712
|
input,
|
|
714
|
-
input
|
|
713
|
+
lenOrView(input),
|
|
715
714
|
output,
|
|
716
|
-
output
|
|
715
|
+
lenOrView(output),
|
|
717
716
|
flag(urlSafe),
|
|
718
717
|
flag(padding ?? true),
|
|
719
718
|
),
|
|
@@ -731,9 +730,9 @@ function build(
|
|
|
731
730
|
const w = Number(
|
|
732
731
|
base64Encode(
|
|
733
732
|
input,
|
|
734
|
-
input
|
|
733
|
+
lenOrView(input),
|
|
735
734
|
output,
|
|
736
|
-
output
|
|
735
|
+
lenOrView(output),
|
|
737
736
|
flag(urlSafe),
|
|
738
737
|
flag(padding ?? true),
|
|
739
738
|
),
|
|
@@ -743,60 +742,110 @@ function build(
|
|
|
743
742
|
return writeOrThrow(w, input.length, 'base64 encode')
|
|
744
743
|
}
|
|
745
744
|
|
|
745
|
+
// cstring-return (the engine clones the NUL-terminated string at call time)
|
|
746
|
+
// → the JS string directly (native transfer — zero encode); a `null` return
|
|
747
|
+
// is the C failure sentinel → throw.
|
|
748
|
+
const cstr = (s: string | null, label: string): string => {
|
|
749
|
+
if (s === null) throw new Error(label)
|
|
750
|
+
return s
|
|
751
|
+
}
|
|
752
|
+
|
|
746
753
|
const etagInto = (data: Uint8Array, output: Uint8Array, weak?: boolean): number => {
|
|
747
|
-
|
|
748
|
-
|
|
754
|
+
// Native pooled `_into`: writes 10/12 bytes directly into the caller
|
|
755
|
+
// buffer (no cstring round-trip). Needed-size convention: a write larger
|
|
756
|
+
// than `output.length` reports the exact required size → throw.
|
|
757
|
+
const w = Number(etagIntoRaw(data, lenOrView(data), flag(weak), output, lenOrView(output)))
|
|
758
|
+
if (w === 0) {
|
|
759
|
+
throw new Error('etag: invalid input')
|
|
760
|
+
}
|
|
761
|
+
if (w > output.length) {
|
|
749
762
|
throw new Error('etag: output buffer too small')
|
|
750
763
|
}
|
|
751
764
|
return w
|
|
752
765
|
}
|
|
753
766
|
|
|
767
|
+
// Pooled 9-byte scratch for jsonSumIds (the packed `[u8 ok][i64 sum LE]`
|
|
768
|
+
// output). The wrapper returns the BigInt VALUE (no byte aliasing escapes),
|
|
769
|
+
// so a single reused buffer + cached DataView is safe — removes the per-call
|
|
770
|
+
// Uint8Array + DataView allocs (measured ~400ns of the FFI wrapper cost).
|
|
771
|
+
const jsonSumOut = new Uint8Array(9)
|
|
772
|
+
const jsonSumView = new DataView(jsonSumOut.buffer)
|
|
773
|
+
|
|
774
|
+
// ── Reusable scratch for the STRING-returning encode wrappers ────────
|
|
775
|
+
// Each wrapper writes its result and decodes it into an immutable JS string
|
|
776
|
+
// (or primitive) SYNCHRONOUSLY before the next call reuses the buffer — the
|
|
777
|
+
// same safety rationale as `jsonSumOut` / `generateRequestId`. Per-Worker
|
|
778
|
+
// module state (single-threaded event loop); never shared across Workers.
|
|
779
|
+
// NOT used for byte-returning ops (hexDecode/urlDecode/base64Decode/aead/
|
|
780
|
+
// wsFrame/packed parsers): their returned `Uint8Array` aliases the buffer
|
|
781
|
+
// and escapes to the caller — those stay on `*Into` (caller-owned).
|
|
782
|
+
let encodeScratch = new Uint8Array(0)
|
|
783
|
+
const scratchFor = (size: number): Uint8Array => {
|
|
784
|
+
if (encodeScratch.byteLength < size) encodeScratch = new Uint8Array(size)
|
|
785
|
+
return encodeScratch
|
|
786
|
+
}
|
|
787
|
+
// Fixed-size packed-verdict scratch + cached DataViews (parseHttpDate 9 B,
|
|
788
|
+
// rate-limit check 13 B) — same pooled pattern as `jsonSumView`. Kept
|
|
789
|
+
// separate from the growable `encodeScratch` so the cached DataViews are
|
|
790
|
+
// never invalidated by a buffer replacement.
|
|
791
|
+
const dateScratch = new Uint8Array(9)
|
|
792
|
+
const dateScratchView = new DataView(dateScratch.buffer)
|
|
793
|
+
const rateScratch = new Uint8Array(13)
|
|
794
|
+
const rateScratchView = new DataView(rateScratch.buffer)
|
|
795
|
+
|
|
754
796
|
return {
|
|
755
797
|
crc32: (input) => Number(oneArg(crc32, input)) >>> 0,
|
|
756
798
|
fnv1a64: (input) => BigInt(oneArg(fnv, input)),
|
|
757
799
|
xxh3: (input) => BigInt(oneArg(xxh, input)),
|
|
758
800
|
jsonValid: (input) => Number(oneArg(jsonValid, input)) === 1,
|
|
801
|
+
utf8Valid: (input) => Number(oneArg(utf8Valid, input)) === 1,
|
|
759
802
|
hexEncode(input) {
|
|
760
|
-
|
|
803
|
+
// Pooled scratch — decoded synchronously to an immutable string (safe;
|
|
804
|
+
// removes the per-call `new Uint8Array(len*2)`). ALWAYS decode the
|
|
805
|
+
// written subarray: the shared scratch may be larger than `w` (grown by
|
|
806
|
+
// an earlier op), so decoding the whole buffer would read stale bytes.
|
|
807
|
+
const out = scratchFor(input.length * 2)
|
|
761
808
|
const w = hexEncodeInto(input, out)
|
|
762
|
-
return
|
|
809
|
+
return decodeUtf8(out.subarray(0, w))
|
|
763
810
|
},
|
|
764
811
|
hexEncodeInto,
|
|
765
812
|
urlEncode(input) {
|
|
766
|
-
// RFC 3986 worst case is 3 bytes per input byte (`%XX`).
|
|
767
|
-
|
|
813
|
+
// RFC 3986 worst case is 3 bytes per input byte (`%XX`). Pooled scratch
|
|
814
|
+
// (decoded synchronously — safe).
|
|
815
|
+
const out = scratchFor(input.length * 3)
|
|
768
816
|
const w = urlEncodeInto(input, out)
|
|
769
|
-
return out.subarray(0, w)
|
|
817
|
+
return decodeUtf8(out.subarray(0, w))
|
|
770
818
|
},
|
|
771
819
|
urlEncodeInto,
|
|
772
820
|
|
|
773
|
-
validateEmail: (input) => Number(
|
|
774
|
-
validateUuid: (input) => Number(
|
|
775
|
-
validateIpv4: (input) => Number(
|
|
776
|
-
validateIpv6: (input) => Number(
|
|
821
|
+
validateEmail: (input) => Number(validateEmail(input)) === 1,
|
|
822
|
+
validateUuid: (input) => Number(validateUuid(input)) === 1,
|
|
823
|
+
validateIpv4: (input) => Number(validateIpv4(input)) === 1,
|
|
824
|
+
validateIpv6: (input) => Number(validateIpv6(input)) === 1,
|
|
777
825
|
jsonSumIds: (input) => {
|
|
778
826
|
// Packed [u8 ok][i64 sum LE] output (9 B): ok=1 → valid array (the sum
|
|
779
827
|
// may be 0); ok=0 → invalid input. Bytes written: 9/1/0 (0 = real error).
|
|
780
|
-
const
|
|
781
|
-
const w = Number(jsonSumRaw(input, input.length, out, out.length))
|
|
828
|
+
const w = Number(jsonSumRaw(input, lenOrView(input), jsonSumOut, lenOrView(jsonSumOut)))
|
|
782
829
|
if (w === 0) {
|
|
783
830
|
throw new Error('json sum ids: output buffer too small')
|
|
784
831
|
}
|
|
785
|
-
if (
|
|
832
|
+
if (jsonSumOut[0] === 0) {
|
|
786
833
|
// Mirrors the napi error phrasing (serde: "expected an array of objects
|
|
787
834
|
// with numeric ids") so both transports throw the same message.
|
|
788
835
|
throw new Error('json sum ids: expected an array of objects with numeric ids')
|
|
789
836
|
}
|
|
790
|
-
return
|
|
837
|
+
return jsonSumView.getBigInt64(1, true)
|
|
791
838
|
},
|
|
792
839
|
hmacSha256Verify: (key, data, signature) =>
|
|
793
|
-
Number(
|
|
840
|
+
Number(
|
|
841
|
+
hmacVerify(key, lenOrView(key), data, lenOrView(data), signature, lenOrView(signature)),
|
|
842
|
+
) === 1,
|
|
794
843
|
csrfVerify: (token, secret) =>
|
|
795
|
-
Number(csrfVerify(token, token
|
|
844
|
+
Number(csrfVerify(token, lenOrView(token), secret, lenOrView(secret))) === 1,
|
|
796
845
|
passwordVerify: (password, phc) =>
|
|
797
|
-
Number(passwordVerify(password, password
|
|
846
|
+
Number(passwordVerify(password, lenOrView(password), phc, lenOrView(phc))) === 1,
|
|
798
847
|
passwordVerifyBcrypt: (password, phc) =>
|
|
799
|
-
Number(passwordVerifyBcrypt(password, password
|
|
848
|
+
Number(passwordVerifyBcrypt(password, lenOrView(password), phc)) === 1,
|
|
800
849
|
|
|
801
850
|
hexDecode(input) {
|
|
802
851
|
const out = new Uint8Array(Math.floor(input.length / 2))
|
|
@@ -818,121 +867,291 @@ function build(
|
|
|
818
867
|
base64DecodeInto,
|
|
819
868
|
|
|
820
869
|
wsAcceptKey(key) {
|
|
821
|
-
|
|
822
|
-
|
|
870
|
+
return cstr(wsAcceptKey(key), 'ws accept key: bad key')
|
|
871
|
+
},
|
|
872
|
+
wsAcceptKeyInto(key, output) {
|
|
873
|
+
// Native pooled `_into`: writes the 28-byte accept key directly into the
|
|
874
|
+
// caller buffer (no cstring round-trip). Needed-size convention.
|
|
875
|
+
const w = Number(wsAcceptKeyInto(key, lenOrView(key), output, lenOrView(output)))
|
|
823
876
|
if (w === 0) {
|
|
824
|
-
throw new Error('ws accept key: bad key
|
|
877
|
+
throw new Error('ws accept key: bad key')
|
|
825
878
|
}
|
|
826
|
-
|
|
879
|
+
if (w > output.length) {
|
|
880
|
+
throw new Error('ws accept key: output buffer too small')
|
|
881
|
+
}
|
|
882
|
+
return w
|
|
827
883
|
},
|
|
828
884
|
etag(data, weak) {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
return out.subarray(0, w)
|
|
885
|
+
// cstring return (10 strong / 12 weak chars) — zero encode.
|
|
886
|
+
return cstr(etagCStr(data, lenOrView(data), flag(weak)), 'etag: invalid input')
|
|
832
887
|
},
|
|
833
888
|
etagInto,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
889
|
+
conditionalIsNotModified(inner, ifNoneMatch, ifModifiedSince) {
|
|
890
|
+
// Opaque-handle eval of the precompiled `ConditionalRequest` state.
|
|
891
|
+
// flags bit0 = If-None-Match present, bit1 = If-Modified-Since present
|
|
892
|
+
// (present-but-empty is distinct from absent — napi Option parity). A
|
|
893
|
+
// null handle (0) → 0 (never dereferences freed state).
|
|
894
|
+
const flags = (ifNoneMatch === null ? 0 : 1) | (ifModifiedSince === null ? 0 : 2)
|
|
895
|
+
const inm = ifNoneMatch ?? EMPTY_VIEW
|
|
896
|
+
const ims = ifModifiedSince ?? EMPTY_VIEW
|
|
897
|
+
return (
|
|
898
|
+
Number(
|
|
899
|
+
conditionalIsNotModifiedRaw(inner, inm, lenOrView(inm), ims, lenOrView(ims), flags),
|
|
900
|
+
) === 1
|
|
901
|
+
)
|
|
902
|
+
},
|
|
903
|
+
mediaTypeMatcherMatches(inner, actual) {
|
|
904
|
+
// Precompiled expected-type match → u8.
|
|
905
|
+
return Number(mediaTypeMatcherMatchesRaw(inner, actual, lenOrView(actual))) === 1
|
|
906
|
+
},
|
|
907
|
+
acceptNegotiatorNegotiate(inner, header) {
|
|
908
|
+
// cstring best-supported encoding; `null` = identity (napi Option parity).
|
|
909
|
+
return acceptNegotiatorNegotiateRaw(inner, header, lenOrView(header))
|
|
910
|
+
},
|
|
911
|
+
jwtSignerSign(inner, claimsJson, nowSeconds) {
|
|
912
|
+
// Precompiled key + ttl → compact token. 0 = invalid claims JSON (real
|
|
913
|
+
// error → growExact throws); w > output.length = exact needed size.
|
|
914
|
+
return growExact(
|
|
915
|
+
(out) =>
|
|
916
|
+
Number(
|
|
917
|
+
jwtSignerSignRaw(
|
|
918
|
+
inner,
|
|
919
|
+
claimsJson,
|
|
920
|
+
lenOrView(claimsJson),
|
|
921
|
+
BigInt(nowSeconds),
|
|
922
|
+
out,
|
|
923
|
+
lenOrView(out),
|
|
924
|
+
),
|
|
925
|
+
),
|
|
926
|
+
Math.min(claimsJson.length + 128, 64 * 1024),
|
|
927
|
+
1024 * 1024,
|
|
928
|
+
'jwt signer: invalid claims JSON or output buffer too small',
|
|
929
|
+
)
|
|
930
|
+
},
|
|
931
|
+
jwtSignerVerify(inner, token, nowSeconds) {
|
|
932
|
+
// Precompiled key → claims JSON bytes; 0 = invalid / expired → null.
|
|
933
|
+
const out = new Uint8Array(Math.min(token.length + 256, 64 * 1024))
|
|
934
|
+
const w = Number(
|
|
935
|
+
jwtSignerVerifyRaw(inner, token, lenOrView(token), BigInt(nowSeconds), out, lenOrView(out)),
|
|
936
|
+
)
|
|
937
|
+
if (w === 0) return null
|
|
938
|
+
if (w > out.length) {
|
|
939
|
+
const out2 = new Uint8Array(w)
|
|
940
|
+
const w2 = Number(
|
|
941
|
+
jwtSignerVerifyRaw(
|
|
942
|
+
inner,
|
|
943
|
+
token,
|
|
944
|
+
lenOrView(token),
|
|
945
|
+
BigInt(nowSeconds),
|
|
946
|
+
out2,
|
|
947
|
+
lenOrView(out2),
|
|
948
|
+
),
|
|
949
|
+
)
|
|
950
|
+
return w2 === 0 ? null : out2.subarray(0, w2)
|
|
841
951
|
}
|
|
842
952
|
return out.subarray(0, w)
|
|
843
953
|
},
|
|
954
|
+
templateRender(inner, contextJson) {
|
|
955
|
+
// Compiled template + pre-serialized JSON context → UTF-8 bytes. 0 =
|
|
956
|
+
// invalid context / render error (real error → growExact throws).
|
|
957
|
+
return growExact(
|
|
958
|
+
(out) =>
|
|
959
|
+
Number(
|
|
960
|
+
templateRenderRaw(inner, contextJson, lenOrView(contextJson), out, lenOrView(out)),
|
|
961
|
+
),
|
|
962
|
+
Math.min(contextJson.length + 128, 64 * 1024),
|
|
963
|
+
1024 * 1024,
|
|
964
|
+
'template render: invalid context JSON or render failed',
|
|
965
|
+
)
|
|
966
|
+
},
|
|
967
|
+
schemaValidatorValidate(inner, doc) {
|
|
968
|
+
return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
|
|
969
|
+
},
|
|
970
|
+
rateLimiterCheck(inner, key, nowMs) {
|
|
971
|
+
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
972
|
+
// Reused scratch + cached DataView (no per-call allocs). `key` is a
|
|
973
|
+
// `cstring` ARG (the engine transcodes the JS string in-engine).
|
|
974
|
+
const out = rateScratch
|
|
975
|
+
const w = Number(
|
|
976
|
+
rateLimiterCheckRaw(inner, key, BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
|
|
977
|
+
)
|
|
978
|
+
if (w === 0) throw new Error('rate limiter check: null handle')
|
|
979
|
+
return unpackRateCheck(out, rateScratchView)
|
|
980
|
+
},
|
|
981
|
+
rateLimiterCheckKey(inner, key, nowMs) {
|
|
982
|
+
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
983
|
+
// Reused scratch + cached DataView (no per-call allocs).
|
|
984
|
+
const out = rateScratch
|
|
985
|
+
const w = Number(
|
|
986
|
+
rateLimiterCheckKeyRaw(inner, BigInt(key), BigInt(Math.trunc(nowMs)), out, lenOrView(out)),
|
|
987
|
+
)
|
|
988
|
+
if (w === 0) throw new Error('rate limiter check: null handle')
|
|
989
|
+
return unpackRateCheck(out, rateScratchView)
|
|
990
|
+
},
|
|
991
|
+
randomToken(byteLen) {
|
|
992
|
+
// cstring return of `byteLen*2` hex chars; byteLen 0 → empty string → empty
|
|
993
|
+
// Uint8Array (napi returns empty too). null = random source failed / >16MiB.
|
|
994
|
+
return cstr(
|
|
995
|
+
randomToken(byteLen),
|
|
996
|
+
'random token: output buffer too small or random source failed',
|
|
997
|
+
)
|
|
998
|
+
},
|
|
999
|
+
randomTokenInto(byteLen, output) {
|
|
1000
|
+
// Pooled sibling: native writes `byteLen*2` hex chars directly into the
|
|
1001
|
+
// caller buffer (no cstring round-trip). Needed-size convention: a write
|
|
1002
|
+
// larger than `output.length` reports the exact required size → throw
|
|
1003
|
+
// (the caller owns the buffer); 0 = real error (cap / RNG).
|
|
1004
|
+
const w = Number(randomTokenInto(byteLen, output, lenOrView(output)))
|
|
1005
|
+
if (w === 0) {
|
|
1006
|
+
throw new Error('random token: random source failed or byteLen exceeds 16 MiB')
|
|
1007
|
+
}
|
|
1008
|
+
if (w > output.length) {
|
|
1009
|
+
throw new Error('random token: output buffer too small')
|
|
1010
|
+
}
|
|
1011
|
+
return w
|
|
1012
|
+
},
|
|
844
1013
|
base64Encode(input, urlSafe, padding) {
|
|
845
|
-
|
|
1014
|
+
// Pooled scratch — decoded synchronously to an immutable string (safe).
|
|
1015
|
+
const out = scratchFor(Math.ceil(input.length / 3) * 4)
|
|
846
1016
|
const w = base64EncodeInto(input, out, urlSafe, padding)
|
|
847
|
-
return out.subarray(0, w)
|
|
1017
|
+
return decodeUtf8(out.subarray(0, w))
|
|
848
1018
|
},
|
|
849
1019
|
base64EncodeInto,
|
|
850
1020
|
hmacSha256(key, data) {
|
|
851
|
-
|
|
852
|
-
const
|
|
1021
|
+
// Pooled 64-byte scratch — decoded synchronously (safe).
|
|
1022
|
+
const out = scratchFor(64)
|
|
1023
|
+
const w = Number(hmacSha256(key, lenOrView(key), data, lenOrView(data), out, lenOrView(out)))
|
|
853
1024
|
if (w === 0) {
|
|
854
1025
|
throw new Error('hmac sha256: output buffer too small')
|
|
855
1026
|
}
|
|
856
|
-
return out.subarray(0, w)
|
|
1027
|
+
return decodeUtf8(out.subarray(0, w))
|
|
857
1028
|
},
|
|
858
1029
|
hmacSha256Into(key, data, output) {
|
|
859
1030
|
if (output.length < 64) {
|
|
860
1031
|
throw new Error('hmac sha256: output buffer too small')
|
|
861
1032
|
}
|
|
862
|
-
const w = Number(
|
|
1033
|
+
const w = Number(
|
|
1034
|
+
hmacSha256(key, lenOrView(key), data, lenOrView(data), output, lenOrView(output)),
|
|
1035
|
+
)
|
|
863
1036
|
if (w === 0) {
|
|
864
1037
|
throw new Error('hmac sha256: output buffer too small')
|
|
865
1038
|
}
|
|
866
1039
|
return w
|
|
867
1040
|
},
|
|
868
1041
|
signCookie(value, secret) {
|
|
869
|
-
// `value.<64-hex>`
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
return out.subarray(0, w)
|
|
1042
|
+
// `value.<64-hex>` returned as a cstring (value.length + 1 + 64 chars).
|
|
1043
|
+
return cstr(
|
|
1044
|
+
signCookie(value, lenOrView(value), secret, lenOrView(secret)),
|
|
1045
|
+
'sign cookie: invalid input',
|
|
1046
|
+
)
|
|
876
1047
|
},
|
|
877
1048
|
signCookieInto(value, secret, output) {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
}
|
|
1049
|
+
// Native pooled `_into`: writes `value.<64-hex>` directly into the caller
|
|
1050
|
+
// buffer (no cstring round-trip — this is why pooled sign_cookie was
|
|
1051
|
+
// previously a REGRESSION vs allocating). Needed-size convention.
|
|
882
1052
|
const w = Number(
|
|
883
|
-
|
|
1053
|
+
signCookieInto(
|
|
1054
|
+
value,
|
|
1055
|
+
lenOrView(value),
|
|
1056
|
+
secret,
|
|
1057
|
+
lenOrView(secret),
|
|
1058
|
+
output,
|
|
1059
|
+
lenOrView(output),
|
|
1060
|
+
),
|
|
884
1061
|
)
|
|
885
1062
|
if (w === 0) {
|
|
1063
|
+
throw new Error('sign cookie: invalid input')
|
|
1064
|
+
}
|
|
1065
|
+
if (w > output.length) {
|
|
886
1066
|
throw new Error('sign cookie: output buffer too small')
|
|
887
1067
|
}
|
|
888
1068
|
return w
|
|
889
1069
|
},
|
|
890
1070
|
verifyCookie(signed, secret) {
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1071
|
+
// cstring return; `null` = invalid signature / malformed → null (napi parity).
|
|
1072
|
+
// CAVEAT (refactor): the engine clones a UTF-8 string, so a cookie VALUE
|
|
1073
|
+
// containing non-UTF-8 bytes or NUL cannot round-trip byte-faithfully on
|
|
1074
|
+
// the FFI path (napi still does). Signed values are ASCII in practice.
|
|
1075
|
+
const s = verifyCookie(signed, lenOrView(signed), secret, lenOrView(secret))
|
|
1076
|
+
return s === null ? null : s
|
|
1077
|
+
},
|
|
1078
|
+
verifyCookieInto(signed, secret, output) {
|
|
1079
|
+
// Native pooled `_into`: writes the verified value directly into the
|
|
1080
|
+
// caller buffer. 0 = invalid signature / malformed → null (napi parity,
|
|
1081
|
+
// like the allocating `verifyCookie`); a write larger than `output.length`
|
|
1082
|
+
// reports the exact required size → throw.
|
|
1083
|
+
const w = Number(
|
|
1084
|
+
verifyCookieInto(
|
|
1085
|
+
signed,
|
|
1086
|
+
lenOrView(signed),
|
|
1087
|
+
secret,
|
|
1088
|
+
lenOrView(secret),
|
|
1089
|
+
output,
|
|
1090
|
+
lenOrView(output),
|
|
1091
|
+
),
|
|
1092
|
+
)
|
|
1093
|
+
if (w === 0) {
|
|
1094
|
+
return null
|
|
1095
|
+
}
|
|
1096
|
+
if (w > output.length) {
|
|
1097
|
+
throw new Error('verify cookie: output buffer too small')
|
|
1098
|
+
}
|
|
1099
|
+
return w
|
|
894
1100
|
},
|
|
895
1101
|
csrfToken(secret) {
|
|
896
|
-
|
|
897
|
-
|
|
1102
|
+
// 129-char `hex.hex` cstring return.
|
|
1103
|
+
return cstr(
|
|
1104
|
+
csrfToken(secret, lenOrView(secret)),
|
|
1105
|
+
'csrf token: output buffer too small or random source failed',
|
|
1106
|
+
)
|
|
1107
|
+
},
|
|
1108
|
+
csrfTokenInto(secret, output) {
|
|
1109
|
+
// Native pooled `_into`: writes the 129-char `hex.hex` token directly
|
|
1110
|
+
// into the caller buffer (no cstring round-trip). Needed-size convention.
|
|
1111
|
+
const w = Number(csrfTokenInto(secret, lenOrView(secret), output, lenOrView(output)))
|
|
898
1112
|
if (w === 0) {
|
|
899
|
-
throw new Error('csrf token:
|
|
1113
|
+
throw new Error('csrf token: random source failed')
|
|
900
1114
|
}
|
|
901
|
-
|
|
1115
|
+
if (w > output.length) {
|
|
1116
|
+
throw new Error('csrf token: output buffer too small')
|
|
1117
|
+
}
|
|
1118
|
+
return w
|
|
902
1119
|
},
|
|
903
1120
|
passwordHash(password, salt, mCost, tCost, pCost, outLen) {
|
|
904
1121
|
// Pre-size with the EXACT PHC string length (computable from the params),
|
|
905
1122
|
// so the hash runs once — a grow-retry would re-run the whole argon2
|
|
906
1123
|
// hash on a miss. growExact remains the safety net.
|
|
907
|
-
return
|
|
908
|
-
(
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1124
|
+
return decodeUtf8(
|
|
1125
|
+
growExact(
|
|
1126
|
+
(out) =>
|
|
1127
|
+
Number(
|
|
1128
|
+
passwordHash(
|
|
1129
|
+
password,
|
|
1130
|
+
lenOrView(password),
|
|
1131
|
+
salt,
|
|
1132
|
+
lenOrView(salt),
|
|
1133
|
+
mCost,
|
|
1134
|
+
tCost,
|
|
1135
|
+
pCost,
|
|
1136
|
+
outLen,
|
|
1137
|
+
out,
|
|
1138
|
+
lenOrView(out),
|
|
1139
|
+
),
|
|
921
1140
|
),
|
|
922
|
-
),
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1141
|
+
argon2PhcLength(mCost, tCost, pCost, salt.length, outLen),
|
|
1142
|
+
2 * 1024 * 1024,
|
|
1143
|
+
'password hash: output buffer too small',
|
|
1144
|
+
),
|
|
926
1145
|
)
|
|
927
1146
|
},
|
|
928
1147
|
passwordHashBcrypt(password, cost) {
|
|
929
1148
|
// `$2b$CC$` + 22 salt chars + 31 hash chars = 60 chars.
|
|
930
1149
|
const out = new Uint8Array(64)
|
|
931
|
-
const w = Number(passwordHashBcrypt(password, password
|
|
1150
|
+
const w = Number(passwordHashBcrypt(password, lenOrView(password), cost, out, lenOrView(out)))
|
|
932
1151
|
if (w === 0) {
|
|
933
1152
|
throw new Error('password hash bcrypt: output buffer too small')
|
|
934
1153
|
}
|
|
935
|
-
return out.subarray(0, w)
|
|
1154
|
+
return decodeUtf8(out.subarray(0, w))
|
|
936
1155
|
},
|
|
937
1156
|
pbkdf2Sha256(password, salt, rounds, dkLen) {
|
|
938
1157
|
// Rust clamps dkLen to [1, 1MiB] (PBKDF2_MIN_LEN/MAX_LEN) AFTER sizing its
|
|
@@ -940,7 +1159,16 @@ function build(
|
|
|
940
1159
|
const dk = Math.min(Math.max(dkLen, 1), 1024 * 1024)
|
|
941
1160
|
const out = new Uint8Array(dk)
|
|
942
1161
|
const w = Number(
|
|
943
|
-
pbkdf2(
|
|
1162
|
+
pbkdf2(
|
|
1163
|
+
password,
|
|
1164
|
+
lenOrView(password),
|
|
1165
|
+
salt,
|
|
1166
|
+
lenOrView(salt),
|
|
1167
|
+
rounds,
|
|
1168
|
+
dkLen,
|
|
1169
|
+
out,
|
|
1170
|
+
lenOrView(out),
|
|
1171
|
+
),
|
|
944
1172
|
)
|
|
945
1173
|
if (w === 0) {
|
|
946
1174
|
throw new Error('pbkdf2: output buffer too small')
|
|
@@ -953,14 +1181,14 @@ function build(
|
|
|
953
1181
|
const w = Number(
|
|
954
1182
|
aeadEncrypt(
|
|
955
1183
|
key,
|
|
956
|
-
key
|
|
1184
|
+
lenOrView(key),
|
|
957
1185
|
nonce,
|
|
958
|
-
nonce
|
|
1186
|
+
lenOrView(nonce),
|
|
959
1187
|
plaintext,
|
|
960
|
-
plaintext
|
|
1188
|
+
lenOrView(plaintext),
|
|
961
1189
|
algorithm,
|
|
962
1190
|
out,
|
|
963
|
-
out
|
|
1191
|
+
lenOrView(out),
|
|
964
1192
|
),
|
|
965
1193
|
)
|
|
966
1194
|
if (w === 0) {
|
|
@@ -976,14 +1204,14 @@ function build(
|
|
|
976
1204
|
const w = Number(
|
|
977
1205
|
aeadEncrypt(
|
|
978
1206
|
key,
|
|
979
|
-
key
|
|
1207
|
+
lenOrView(key),
|
|
980
1208
|
nonce,
|
|
981
|
-
nonce
|
|
1209
|
+
lenOrView(nonce),
|
|
982
1210
|
plaintext,
|
|
983
|
-
plaintext
|
|
1211
|
+
lenOrView(plaintext),
|
|
984
1212
|
algorithm,
|
|
985
1213
|
output,
|
|
986
|
-
output
|
|
1214
|
+
lenOrView(output),
|
|
987
1215
|
),
|
|
988
1216
|
)
|
|
989
1217
|
if (w === 0) {
|
|
@@ -996,14 +1224,14 @@ function build(
|
|
|
996
1224
|
const w = Number(
|
|
997
1225
|
aeadDecrypt(
|
|
998
1226
|
key,
|
|
999
|
-
key
|
|
1227
|
+
lenOrView(key),
|
|
1000
1228
|
nonce,
|
|
1001
|
-
nonce
|
|
1229
|
+
lenOrView(nonce),
|
|
1002
1230
|
ciphertext,
|
|
1003
|
-
ciphertext
|
|
1231
|
+
lenOrView(ciphertext),
|
|
1004
1232
|
algorithm,
|
|
1005
1233
|
out,
|
|
1006
|
-
out
|
|
1234
|
+
lenOrView(out),
|
|
1007
1235
|
),
|
|
1008
1236
|
)
|
|
1009
1237
|
return w === 0 ? null : out.subarray(0, w)
|
|
@@ -1013,7 +1241,15 @@ function build(
|
|
|
1013
1241
|
// Header (max 10) + payload + mask key (4).
|
|
1014
1242
|
const out = new Uint8Array(payload.length + 14)
|
|
1015
1243
|
const w = Number(
|
|
1016
|
-
wsFrameEncode(
|
|
1244
|
+
wsFrameEncode(
|
|
1245
|
+
opcode,
|
|
1246
|
+
payload,
|
|
1247
|
+
lenOrView(payload),
|
|
1248
|
+
flag(mask),
|
|
1249
|
+
flag(fin),
|
|
1250
|
+
out,
|
|
1251
|
+
lenOrView(out),
|
|
1252
|
+
),
|
|
1017
1253
|
)
|
|
1018
1254
|
if (w === 0) {
|
|
1019
1255
|
throw new Error('ws frame encode: output buffer too small')
|
|
@@ -1030,11 +1266,11 @@ function build(
|
|
|
1030
1266
|
wsFrameEncode(
|
|
1031
1267
|
opcode,
|
|
1032
1268
|
payload,
|
|
1033
|
-
payload
|
|
1269
|
+
lenOrView(payload),
|
|
1034
1270
|
flag(mask),
|
|
1035
1271
|
flag(fin),
|
|
1036
1272
|
output,
|
|
1037
|
-
output
|
|
1273
|
+
lenOrView(output),
|
|
1038
1274
|
),
|
|
1039
1275
|
)
|
|
1040
1276
|
if (w === 0) {
|
|
@@ -1043,11 +1279,14 @@ function build(
|
|
|
1043
1279
|
return w
|
|
1044
1280
|
},
|
|
1045
1281
|
jsonPatch(doc, patch) {
|
|
1046
|
-
return
|
|
1047
|
-
(
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1282
|
+
return decodeUtf8(
|
|
1283
|
+
growExact(
|
|
1284
|
+
(out) =>
|
|
1285
|
+
Number(jsonPatch(doc, lenOrView(doc), patch, lenOrView(patch), out, lenOrView(out))),
|
|
1286
|
+
Math.min(Math.max(doc.length, patch.length) + 16, 64 * 1024),
|
|
1287
|
+
MAX_JSON_PATCH_OUTPUT,
|
|
1288
|
+
'json patch: output buffer too small or patch inapplicable',
|
|
1289
|
+
),
|
|
1051
1290
|
)
|
|
1052
1291
|
},
|
|
1053
1292
|
gzipCompress(data, level = 6) {
|
|
@@ -1058,14 +1297,17 @@ function build(
|
|
|
1058
1297
|
// single-pass). growExact handles incompressible data with at most one
|
|
1059
1298
|
// exact retry (no re-run loop).
|
|
1060
1299
|
return growExact(
|
|
1061
|
-
(out) =>
|
|
1300
|
+
(out) =>
|
|
1301
|
+
Number(gzipCompress(data, lenOrView(data), Math.min(level, 9), out, lenOrView(out))),
|
|
1062
1302
|
Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
|
|
1063
1303
|
Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
|
|
1064
1304
|
'gzip compress: output buffer too small',
|
|
1065
1305
|
)
|
|
1066
1306
|
},
|
|
1067
1307
|
gzipCompressInto(data, output, level = 6) {
|
|
1068
|
-
const w = Number(
|
|
1308
|
+
const w = Number(
|
|
1309
|
+
gzipCompress(data, lenOrView(data), Math.min(level, 9), output, lenOrView(output)),
|
|
1310
|
+
)
|
|
1069
1311
|
// Needed-size convention: w > output.length = too small; w === 0 = real error.
|
|
1070
1312
|
if (w === 0) {
|
|
1071
1313
|
throw new Error('gzip compress: invalid input')
|
|
@@ -1082,23 +1324,40 @@ function build(
|
|
|
1082
1324
|
// back to a multiplier guess. growExact handles any residual miss with
|
|
1083
1325
|
// one exact retry, and invalid input throws immediately (no 64 MiB
|
|
1084
1326
|
// grow-to-max allocation storm).
|
|
1085
|
-
const isize = Number(gzipIsize(data, data
|
|
1327
|
+
const isize = Number(gzipIsize(data, lenOrView(data)))
|
|
1086
1328
|
const initial =
|
|
1087
1329
|
isize !== 0
|
|
1088
1330
|
? Math.min(isize, max)
|
|
1089
1331
|
: Math.min(data.length * DECOMPRESS_GUESS_MULTIPLIER_GZIP, DECOMPRESS_FALLBACK_CAP)
|
|
1090
1332
|
return growExact(
|
|
1091
|
-
(out) => Number(gzipDecompress(data, data
|
|
1333
|
+
(out) => Number(gzipDecompress(data, lenOrView(data), max, out, lenOrView(out))),
|
|
1092
1334
|
initial,
|
|
1093
1335
|
max,
|
|
1094
1336
|
'gzip decompress: invalid stream or exceeded max decompressed size',
|
|
1095
1337
|
)
|
|
1096
1338
|
},
|
|
1339
|
+
gzipDecompressInto(data, output, maxDecompressed) {
|
|
1340
|
+
// Pooled sibling — the C ABI streams the decompressed output directly
|
|
1341
|
+
// into the caller buffer via the `_into` core (no internal Vec), keeping
|
|
1342
|
+
// the 64 MiB decompression-bomb cap. Needed-size convention: w === 0 =
|
|
1343
|
+
// real error (invalid stream / cap exceeded); w > output.length = exact
|
|
1344
|
+
// required size → throw (nothing to grow — the caller owns the buffer).
|
|
1345
|
+
const max = maxDecompressed ?? MAX_DECOMPRESSED
|
|
1346
|
+
const w = Number(gzipDecompress(data, lenOrView(data), max, output, lenOrView(output)))
|
|
1347
|
+
if (w === 0) {
|
|
1348
|
+
throw new Error('gzip decompress: invalid stream or exceeded max decompressed size')
|
|
1349
|
+
}
|
|
1350
|
+
if (w > output.length) {
|
|
1351
|
+
throw new Error('gzip decompress: output buffer too small')
|
|
1352
|
+
}
|
|
1353
|
+
return w
|
|
1354
|
+
},
|
|
1097
1355
|
brotliCompress(data, quality = 5) {
|
|
1098
1356
|
// Same cap rationale as gzipCompress (streaming core); growExact for
|
|
1099
1357
|
// incompressible data.
|
|
1100
1358
|
return growExact(
|
|
1101
|
-
(out) =>
|
|
1359
|
+
(out) =>
|
|
1360
|
+
Number(brotliCompress(data, lenOrView(data), Math.min(quality, 11), out, lenOrView(out))),
|
|
1102
1361
|
Math.min(data.length + COMPRESS_HEADROOM, COMPRESS_INITIAL_CAP),
|
|
1103
1362
|
Math.max(data.length * 2 + COMPRESS_HEADROOM, COMPRESS_MAX_CAP),
|
|
1104
1363
|
'brotli compress: output buffer too small',
|
|
@@ -1106,7 +1365,7 @@ function build(
|
|
|
1106
1365
|
},
|
|
1107
1366
|
brotliCompressInto(data, output, quality = 5) {
|
|
1108
1367
|
const w = Number(
|
|
1109
|
-
brotliCompress(data, data
|
|
1368
|
+
brotliCompress(data, lenOrView(data), Math.min(quality, 11), output, lenOrView(output)),
|
|
1110
1369
|
)
|
|
1111
1370
|
// Needed-size convention: w > output.length = too small; w === 0 = real error.
|
|
1112
1371
|
if (w === 0) {
|
|
@@ -1124,7 +1383,7 @@ function build(
|
|
|
1124
1383
|
// cap bounds over-allocation on large streams; growExact handles
|
|
1125
1384
|
// higher-ratio streams with one exact retry.
|
|
1126
1385
|
return growExact(
|
|
1127
|
-
(out) => Number(brotliDecompress(data, data
|
|
1386
|
+
(out) => Number(brotliDecompress(data, lenOrView(data), max, out, lenOrView(out))),
|
|
1128
1387
|
Math.min(
|
|
1129
1388
|
Math.max(data.length * DECOMPRESS_GUESS_MULTIPLIER_BROTLI, DECOMPRESS_MIN_INITIAL),
|
|
1130
1389
|
DECOMPRESS_FALLBACK_CAP,
|
|
@@ -1133,24 +1392,41 @@ function build(
|
|
|
1133
1392
|
'brotli decompress: invalid stream or exceeded max decompressed size',
|
|
1134
1393
|
)
|
|
1135
1394
|
},
|
|
1395
|
+
brotliDecompressInto(data, output, maxDecompressed) {
|
|
1396
|
+
// Pooled sibling — streams into the caller buffer, keeps the 64 MiB cap
|
|
1397
|
+
// (same convention as gzipDecompressInto above).
|
|
1398
|
+
const max = maxDecompressed ?? MAX_DECOMPRESSED
|
|
1399
|
+
const w = Number(brotliDecompress(data, lenOrView(data), max, output, lenOrView(output)))
|
|
1400
|
+
if (w === 0) {
|
|
1401
|
+
throw new Error('brotli decompress: invalid stream or exceeded max decompressed size')
|
|
1402
|
+
}
|
|
1403
|
+
if (w > output.length) {
|
|
1404
|
+
throw new Error('brotli decompress: output buffer too small')
|
|
1405
|
+
}
|
|
1406
|
+
return w
|
|
1407
|
+
},
|
|
1136
1408
|
|
|
1137
1409
|
httpParseRequestPackedInto(input, output) {
|
|
1138
|
-
const w = Number(httpParsePacked(input, input
|
|
1410
|
+
const w = Number(httpParsePacked(input, lenOrView(input), output, lenOrView(output)))
|
|
1139
1411
|
if (w === 0 && input.length !== 0) {
|
|
1140
1412
|
throw new Error('http parse: output buffer too small or malformed request')
|
|
1141
1413
|
}
|
|
1142
1414
|
return w
|
|
1143
1415
|
},
|
|
1144
1416
|
queryParsePackedInto(input, output) {
|
|
1145
|
-
const w = Number(queryParsePacked(input, input
|
|
1146
|
-
|
|
1417
|
+
const w = Number(queryParsePacked(input, lenOrView(input), output, lenOrView(output)))
|
|
1418
|
+
// Needed-size convention: w > output.length = exact required size →
|
|
1419
|
+
// too-small (throw — the caller owns the buffer, nothing to grow);
|
|
1420
|
+
// w === 0 = real error (malformed %XX).
|
|
1421
|
+
if (w === 0 || w > output.length) {
|
|
1147
1422
|
throw new Error('query parse: output buffer too small')
|
|
1148
1423
|
}
|
|
1149
1424
|
return w
|
|
1150
1425
|
},
|
|
1151
1426
|
cookieParsePackedInto(input, output) {
|
|
1152
|
-
const w = Number(cookieParsePacked(input, input
|
|
1153
|
-
|
|
1427
|
+
const w = Number(cookieParsePacked(input, lenOrView(input), output, lenOrView(output)))
|
|
1428
|
+
// Needed-size convention (same as queryParsePackedInto).
|
|
1429
|
+
if (w === 0 || w > output.length) {
|
|
1154
1430
|
throw new Error('cookie parse: output buffer too small')
|
|
1155
1431
|
}
|
|
1156
1432
|
return w
|
|
@@ -1158,71 +1434,283 @@ function build(
|
|
|
1158
1434
|
|
|
1159
1435
|
// ── Excluded-surface additions ──────────────────────────────────
|
|
1160
1436
|
jwtSignBytes(claimsJson, secret, ttl, now) {
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1437
|
+
// Compact HS256 token returned as a cstring (engine-cloned) — the Rust
|
|
1438
|
+
// side builds the whole token and the JS pays zero decode. ttl<=0 = no
|
|
1439
|
+
// iat/exp (napi Option<i64> sentinel). i64 args must be BigInt.
|
|
1440
|
+
return cstr(
|
|
1441
|
+
jwtSignBytes(
|
|
1442
|
+
claimsJson,
|
|
1443
|
+
lenOrView(claimsJson),
|
|
1444
|
+
secret,
|
|
1445
|
+
lenOrView(secret),
|
|
1446
|
+
BigInt(ttl),
|
|
1447
|
+
BigInt(now),
|
|
1448
|
+
),
|
|
1449
|
+
'jwt sign: invalid claims JSON',
|
|
1450
|
+
)
|
|
1451
|
+
},
|
|
1452
|
+
jwtSignBytesInto(claimsJson, secret, ttl, now, output) {
|
|
1453
|
+
// Native pooled `_into`: writes the compact token directly into the
|
|
1454
|
+
// caller buffer (no cstring round-trip). Needed-size convention: a write
|
|
1455
|
+
// larger than `output.length` reports the exact required size → throw;
|
|
1456
|
+
// 0 = invalid claims JSON.
|
|
1457
|
+
const w = Number(
|
|
1458
|
+
jwtSignBytesInto(
|
|
1459
|
+
claimsJson,
|
|
1460
|
+
lenOrView(claimsJson),
|
|
1461
|
+
secret,
|
|
1462
|
+
lenOrView(secret),
|
|
1463
|
+
BigInt(ttl),
|
|
1464
|
+
BigInt(now),
|
|
1465
|
+
output,
|
|
1466
|
+
lenOrView(output),
|
|
1187
1467
|
),
|
|
1188
|
-
1024 * 1024,
|
|
1189
|
-
'jwt sign: output buffer too small',
|
|
1190
1468
|
)
|
|
1469
|
+
if (w === 0) {
|
|
1470
|
+
throw new Error('jwt sign: invalid claims JSON')
|
|
1471
|
+
}
|
|
1472
|
+
if (w > output.length) {
|
|
1473
|
+
throw new Error('jwt sign: output buffer too small')
|
|
1474
|
+
}
|
|
1475
|
+
return w
|
|
1191
1476
|
},
|
|
1192
1477
|
wsFrameDecodePacked(data) {
|
|
1193
1478
|
// Max packed output = 6-byte header + payload.
|
|
1194
1479
|
const out = new Uint8Array(data.length + 6)
|
|
1195
|
-
const w = Number(wsFrameDecodePacked(data, data
|
|
1480
|
+
const w = Number(wsFrameDecodePacked(data, lenOrView(data), out, lenOrView(out)))
|
|
1196
1481
|
return w === 0 ? null : out.subarray(0, w)
|
|
1197
1482
|
},
|
|
1483
|
+
wsFrameDecodePackedInto(data, output) {
|
|
1484
|
+
// Pooled sibling: the caller provides the output buffer, sized to at
|
|
1485
|
+
// least `data.length + 6` (6-byte header + payload — the Rust core
|
|
1486
|
+
// returns 0 for BOTH too-small and malformed, so the caller must size
|
|
1487
|
+
// to the max; the allocating path always does). 0 = malformed → null,
|
|
1488
|
+
// mirroring the allocating path's return contract.
|
|
1489
|
+
const w = Number(wsFrameDecodePacked(data, lenOrView(data), output, lenOrView(output)))
|
|
1490
|
+
return w === 0 ? null : w
|
|
1491
|
+
},
|
|
1198
1492
|
multipartParsePacked(body, boundary) {
|
|
1199
1493
|
return growExact(
|
|
1200
1494
|
(out) =>
|
|
1201
1495
|
Number(
|
|
1202
|
-
multipartParsePacked(
|
|
1496
|
+
multipartParsePacked(
|
|
1497
|
+
body,
|
|
1498
|
+
lenOrView(body),
|
|
1499
|
+
boundary,
|
|
1500
|
+
lenOrView(boundary),
|
|
1501
|
+
out,
|
|
1502
|
+
lenOrView(out),
|
|
1503
|
+
),
|
|
1203
1504
|
),
|
|
1204
1505
|
Math.min(body.length + boundary.length + 64, 64 * 1024),
|
|
1205
1506
|
128 * 1024 * 1024,
|
|
1206
1507
|
'multipart parse: output buffer too small',
|
|
1207
1508
|
)
|
|
1208
1509
|
},
|
|
1510
|
+
multipartParsePackedInto(body, boundary, output) {
|
|
1511
|
+
// Pooled sibling: the caller provides the output buffer. Needed-size
|
|
1512
|
+
// convention (same as queryParsePackedInto): w > output.length = exact
|
|
1513
|
+
// required size → throw; w === 0 = real error (malformed).
|
|
1514
|
+
const w = Number(
|
|
1515
|
+
multipartParsePacked(
|
|
1516
|
+
body,
|
|
1517
|
+
lenOrView(body),
|
|
1518
|
+
boundary,
|
|
1519
|
+
lenOrView(boundary),
|
|
1520
|
+
output,
|
|
1521
|
+
lenOrView(output),
|
|
1522
|
+
),
|
|
1523
|
+
)
|
|
1524
|
+
if (w === 0 || w > output.length) {
|
|
1525
|
+
throw new Error('multipart parse: output buffer too small or malformed body')
|
|
1526
|
+
}
|
|
1527
|
+
return w
|
|
1528
|
+
},
|
|
1209
1529
|
formParsePackedInto(input, output) {
|
|
1210
|
-
const w = Number(formParsePacked(input, input
|
|
1211
|
-
|
|
1530
|
+
const w = Number(formParsePacked(input, lenOrView(input), output, lenOrView(output)))
|
|
1531
|
+
// Needed-size convention (form aliases the query core — same convention).
|
|
1532
|
+
if (w === 0 || w > output.length) {
|
|
1212
1533
|
throw new Error('form parse: output buffer too small')
|
|
1213
1534
|
}
|
|
1214
1535
|
return w
|
|
1215
1536
|
},
|
|
1537
|
+
|
|
1538
|
+
// ── Wired WIP surface (JWT verify, task group) ──
|
|
1539
|
+
jsonParsePacked(input) {
|
|
1540
|
+
// Packed token stream (deduped string table + typed value tree) — the
|
|
1541
|
+
// scalar wrapper assembles the JS value from the tokens with NO second
|
|
1542
|
+
// JSON text parse. `0` = invalid JSON (real error → growExact throws);
|
|
1543
|
+
// `w > out.length` = exact required size (one exact retry).
|
|
1544
|
+
return growExact(
|
|
1545
|
+
(out) => Number(jsonParsePackedSym(input, lenOrView(input), out, lenOrView(out))),
|
|
1546
|
+
Math.min(input.length + (input.length >> 1), 16 * 1024 * 1024),
|
|
1547
|
+
Math.max(1024 * 1024, input.length * 16),
|
|
1548
|
+
'json parse packed: invalid JSON or output buffer too small',
|
|
1549
|
+
)
|
|
1550
|
+
},
|
|
1551
|
+
parseMediaType(input) {
|
|
1552
|
+
// Packed verdict: [u32 mediaTypeLen][mediaType][u32 charsetLen
|
|
1553
|
+
// (0xFFFFFFFF = none)][charset][u32 boundaryLen][boundary]
|
|
1554
|
+
// [u32 paramCount]{[u32 keyLen][key][u32 valLen][val]}. 0 = invalid media
|
|
1555
|
+
// type (real error → growExact throws); w > output.length = exact needed
|
|
1556
|
+
// size (one exact retry).
|
|
1557
|
+
return growExact(
|
|
1558
|
+
(out) => Number(parseMediaTypeSym(input, lenOrView(input), out, lenOrView(out))),
|
|
1559
|
+
Math.min(input.length + 64, 64 * 1024),
|
|
1560
|
+
1024 * 1024,
|
|
1561
|
+
'media type parse: invalid media type or output buffer too small',
|
|
1562
|
+
)
|
|
1563
|
+
},
|
|
1564
|
+
parseHttpDate(input) {
|
|
1565
|
+
// Packed [u8 ok][i64 secs LE] (9 B) / 1 B (ok=0). A too-small buffer is
|
|
1566
|
+
// never a 0 here — the Rust side reports the exact size (9/1) — so a 9-byte
|
|
1567
|
+
// buffer always succeeds. Reused scratch + cached DataView (no per-call
|
|
1568
|
+
// allocs).
|
|
1569
|
+
const out = dateScratch
|
|
1570
|
+
const w = Number(parseHttpDateSym(input, lenOrView(input), out, lenOrView(out)))
|
|
1571
|
+
if (w === 0) {
|
|
1572
|
+
throw new Error('http date parse: output buffer too small')
|
|
1573
|
+
}
|
|
1574
|
+
if (out[0] === 0) return null
|
|
1575
|
+
return dateScratchView.getBigInt64(1, true)
|
|
1576
|
+
},
|
|
1577
|
+
parseAcceptEncoding(input) {
|
|
1578
|
+
// Packed: [u32 count]{[u32 encLen][enc][f32 q][u32 order]} (empty header →
|
|
1579
|
+
// count 0, 4 bytes). 0 = too small (real error → growExact throws).
|
|
1580
|
+
return growExact(
|
|
1581
|
+
(out) => Number(parseAcceptEncodingSym(input, lenOrView(input), out, lenOrView(out))),
|
|
1582
|
+
Math.min(input.length + 64, 64 * 1024),
|
|
1583
|
+
1024 * 1024,
|
|
1584
|
+
'accept encoding parse: output buffer too small',
|
|
1585
|
+
)
|
|
1586
|
+
},
|
|
1587
|
+
urlEncodeQuery(input) {
|
|
1588
|
+
// Packed pairs `[u32 count]{[u32 keyLen][key][u32 valLen][val]}` (the JS
|
|
1589
|
+
// `packPairs` layout) → percent-encoded query TEXT as a cstring, keys
|
|
1590
|
+
// SORTED (matches the napi BTreeMap ordering). `null` = malformed packed
|
|
1591
|
+
// input / non-UTF-8 (napi parity: throws).
|
|
1592
|
+
return urlEncodeQuerySym(input, lenOrView(input))
|
|
1593
|
+
},
|
|
1594
|
+
urlResolve(base, reference) {
|
|
1595
|
+
// RFC 3986 resolution → cstring; `null` = non-UTF-8 input (napi parity).
|
|
1596
|
+
return urlResolveSym(base, lenOrView(base), reference, lenOrView(reference))
|
|
1597
|
+
},
|
|
1598
|
+
urlBuilderResolve(inner, reference) {
|
|
1599
|
+
// Opaque-handle resolve against a `UrlBuilder`'s PRECOMPILED base. 0 =
|
|
1600
|
+
// null handle / non-UTF-8 reference (real error → growExact throws);
|
|
1601
|
+
// w > output.length = exact needed size (one exact retry).
|
|
1602
|
+
return growExact(
|
|
1603
|
+
(out) =>
|
|
1604
|
+
Number(urlBuilderResolveRaw(inner, reference, lenOrView(reference), out, lenOrView(out))),
|
|
1605
|
+
Math.min(reference.length * 2 + 128, 64 * 1024),
|
|
1606
|
+
1024 * 1024,
|
|
1607
|
+
'url builder resolve: invalid reference or output buffer too small',
|
|
1608
|
+
)
|
|
1609
|
+
},
|
|
1610
|
+
mimeFromExtension(ext) {
|
|
1611
|
+
// Extension → MIME → cstring (cstring ARG: the engine transcodes the JS
|
|
1612
|
+
// extension in-engine); unknown → `application/octet-stream` (never null).
|
|
1613
|
+
return mimeFromExtensionSym(ext)
|
|
1614
|
+
},
|
|
1615
|
+
jwtVerify(token, secret, nowSeconds) {
|
|
1616
|
+
// Verify an HS256 JWT → claims as a JSON cstring; `null` = invalid
|
|
1617
|
+
// signature / expired / malformed (napi Option parity). `now` is an i64
|
|
1618
|
+
// C arg → must be passed as BigInt.
|
|
1619
|
+
return jwtVerifySym(token, lenOrView(token), secret, lenOrView(secret), BigInt(nowSeconds))
|
|
1620
|
+
},
|
|
1621
|
+
|
|
1622
|
+
// ── Napi-only hot ops, now FFI (kills the napi crossing) ──────
|
|
1623
|
+
httpDateInto(secs, output) {
|
|
1624
|
+
// 29-byte HTTP-date into the caller buffer. 0 = too-small buffer or
|
|
1625
|
+
// out-of-range year (napi httpDateInto throws on both).
|
|
1626
|
+
const w = Number(httpDateIntoRaw(secs, output, lenOrView(output)))
|
|
1627
|
+
if (w === 0) {
|
|
1628
|
+
throw new Error('http date: output buffer too small or year out of range')
|
|
1629
|
+
}
|
|
1630
|
+
return w
|
|
1631
|
+
},
|
|
1632
|
+
httpDate(secs) {
|
|
1633
|
+
// Allocating sibling — 32-byte buffer always fits the 29-byte date, so
|
|
1634
|
+
// the only 0 case is an out-of-range year (napi falls back to the
|
|
1635
|
+
// allocating format! there — mirror with Date.toUTCString). Pooled
|
|
1636
|
+
// scratch (decoded synchronously — safe).
|
|
1637
|
+
const out = scratchFor(32)
|
|
1638
|
+
const w = Number(httpDateIntoRaw(secs ?? 0, out, lenOrView(out)))
|
|
1639
|
+
if (w !== 0) return decodeUtf8(out.subarray(0, w))
|
|
1640
|
+
return new Date((secs ?? 0) * 1000).toUTCString()
|
|
1641
|
+
},
|
|
1642
|
+
sseEncodeEvent(event, data, id, retry) {
|
|
1643
|
+
// One SSE event → bytes. event/id are encoded to UTF-8 only when
|
|
1644
|
+
// non-null (flag bits 1/2/4 = present) so a present-but-empty string is
|
|
1645
|
+
// distinct from absent (napi Option parity). growExact with the needed-size
|
|
1646
|
+
// convention; `data.length + 64` covers the common single-line case.
|
|
1647
|
+
const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
|
|
1648
|
+
const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
|
|
1649
|
+
const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
|
|
1650
|
+
return growExact(
|
|
1651
|
+
(out) =>
|
|
1652
|
+
Number(
|
|
1653
|
+
sseEncodeIntoRaw(
|
|
1654
|
+
ev,
|
|
1655
|
+
lenOrView(ev),
|
|
1656
|
+
data,
|
|
1657
|
+
lenOrView(data),
|
|
1658
|
+
idv,
|
|
1659
|
+
lenOrView(idv),
|
|
1660
|
+
flags,
|
|
1661
|
+
retry ?? 0,
|
|
1662
|
+
out,
|
|
1663
|
+
lenOrView(out),
|
|
1664
|
+
),
|
|
1665
|
+
),
|
|
1666
|
+
data.length + 64,
|
|
1667
|
+
1 << 20,
|
|
1668
|
+
'sse encode: output buffer too small',
|
|
1669
|
+
)
|
|
1670
|
+
},
|
|
1671
|
+
sseEncodeEventInto(event, data, id, retry, output) {
|
|
1672
|
+
// Pooled sibling — caller-owned output buffer (sized ≥ `data.length + 64`
|
|
1673
|
+
// for the common single-line case). Needed-size convention: w > output.length
|
|
1674
|
+
// = exact required size → throw; w === 0 = real error (invalid UTF-8).
|
|
1675
|
+
const ev = event === null ? EMPTY_VIEW : encodeUtf8(event)
|
|
1676
|
+
const idv = id === null ? EMPTY_VIEW : encodeUtf8(id)
|
|
1677
|
+
const flags = (event === null ? 0 : 1) | (id === null ? 0 : 2) | (retry === null ? 0 : 4)
|
|
1678
|
+
const w = Number(
|
|
1679
|
+
sseEncodeIntoRaw(
|
|
1680
|
+
ev,
|
|
1681
|
+
lenOrView(ev),
|
|
1682
|
+
data,
|
|
1683
|
+
lenOrView(data),
|
|
1684
|
+
idv,
|
|
1685
|
+
lenOrView(idv),
|
|
1686
|
+
flags,
|
|
1687
|
+
retry ?? 0,
|
|
1688
|
+
output,
|
|
1689
|
+
lenOrView(output),
|
|
1690
|
+
),
|
|
1691
|
+
)
|
|
1692
|
+
if (w === 0) {
|
|
1693
|
+
throw new Error('sse encode: invalid UTF-8 in event/id')
|
|
1694
|
+
}
|
|
1695
|
+
if (w > output.length) {
|
|
1696
|
+
throw new Error('sse encode: output buffer too small')
|
|
1697
|
+
}
|
|
1698
|
+
return w
|
|
1699
|
+
},
|
|
1700
|
+
|
|
1216
1701
|
ingressHandlePacked(inner, input, body, output) {
|
|
1217
1702
|
const w = Number(
|
|
1218
1703
|
ingressHandlePacked(
|
|
1219
1704
|
inner,
|
|
1220
1705
|
input,
|
|
1221
|
-
input
|
|
1706
|
+
lenOrView(input),
|
|
1222
1707
|
body ?? EMPTY_VIEW,
|
|
1223
|
-
|
|
1708
|
+
// Under `buffer_length` the length slot must be a view (the engine
|
|
1709
|
+
// reads byteLength off it) — `EMPTY_VIEW` has byteLength 0, matching
|
|
1710
|
+
// a null body; under `(ptr,usize)` it's the explicit length 0.
|
|
1711
|
+
body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
|
|
1224
1712
|
output,
|
|
1225
|
-
output
|
|
1713
|
+
lenOrView(output),
|
|
1226
1714
|
),
|
|
1227
1715
|
)
|
|
1228
1716
|
if (w === 0) {
|
|
@@ -1230,312 +1718,58 @@ function build(
|
|
|
1230
1718
|
}
|
|
1231
1719
|
return w
|
|
1232
1720
|
},
|
|
1721
|
+
ingressHandleComponents(inner, methodKind, url, ip, rid, headers, body, output) {
|
|
1722
|
+
// `url`/`ip` are passed as JS strings to `cstring` args — the engine
|
|
1723
|
+
// transcodes them to call-scoped NUL-terminated UTF-8 buffers in-engine
|
|
1724
|
+
// (no JS-side `Buffer.write` encode, no frame assembly for URL/IP).
|
|
1725
|
+
const w = Number(
|
|
1726
|
+
ingressHandleComponentsSym(
|
|
1727
|
+
inner,
|
|
1728
|
+
methodKind,
|
|
1729
|
+
url,
|
|
1730
|
+
ip,
|
|
1731
|
+
rid,
|
|
1732
|
+
lenOrView(rid),
|
|
1733
|
+
headers,
|
|
1734
|
+
lenOrView(headers),
|
|
1735
|
+
body ?? EMPTY_VIEW,
|
|
1736
|
+
body ? lenOrView(body) : lenOrView(EMPTY_VIEW),
|
|
1737
|
+
output,
|
|
1738
|
+
lenOrView(output),
|
|
1739
|
+
),
|
|
1740
|
+
)
|
|
1741
|
+
if (w === 0) {
|
|
1742
|
+
throw new Error('ingress components: output buffer too small or pipeline error')
|
|
1743
|
+
}
|
|
1744
|
+
return w
|
|
1745
|
+
},
|
|
1233
1746
|
ingressLayout(out) {
|
|
1234
|
-
const w = Number(ingressLayoutSym(out, out
|
|
1747
|
+
const w = Number(ingressLayoutSym(out, lenOrView(out)))
|
|
1235
1748
|
if (w !== out.length) {
|
|
1236
1749
|
throw new Error('ingress layout: output buffer too small')
|
|
1237
1750
|
}
|
|
1238
1751
|
return w
|
|
1239
1752
|
},
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
/** Verify every bound function against known-good results; false disables ffi. */
|
|
1244
|
-
function selfTest(b: BunFFI): boolean {
|
|
1245
|
-
const dec = new TextDecoder()
|
|
1246
|
-
const enc = encoder
|
|
1247
|
-
|
|
1248
|
-
if (b.crc32(enc.encode('123456789')) !== 0xcbf4_3926) {
|
|
1249
|
-
return false
|
|
1250
|
-
}
|
|
1251
|
-
if (b.fnv1a64(enc.encode('foobar')) !== 0x8594_4171_f739_67e8n) {
|
|
1252
|
-
return false
|
|
1253
|
-
}
|
|
1254
|
-
// XXH3-64 of empty input = 0x2d06800538d394c2 (standard reference vector).
|
|
1255
|
-
if (b.xxh3(new Uint8Array(0)) !== 0x2d06800538d394c2n) {
|
|
1256
|
-
return false
|
|
1257
|
-
}
|
|
1258
|
-
if (b.jsonValid(SELFTEST_JSON) !== true || b.jsonValid(enc.encode('{not json')) !== false) {
|
|
1259
|
-
return false
|
|
1260
|
-
}
|
|
1261
|
-
const hexOut = new Uint8Array(SELFTEST_HEX.length * 2)
|
|
1262
|
-
if (b.hexEncodeInto(SELFTEST_HEX, hexOut) !== 10 || dec.decode(hexOut) !== '68656c6c6f') {
|
|
1263
|
-
return false
|
|
1264
|
-
}
|
|
1265
|
-
const urlInput = enc.encode('a b/c')
|
|
1266
|
-
const urlOut = new Uint8Array(9)
|
|
1267
|
-
if (b.urlEncodeInto(urlInput, urlOut) !== 9 || dec.decode(urlOut) !== 'a%20b%2Fc') {
|
|
1268
|
-
return false
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
// Ingress layout blob (38 × u32 LE). The pinned values catch a reordered
|
|
1272
|
-
// `#[repr(C)] IngressLayout` (drift → self-test fails → napi fallback); the
|
|
1273
|
-
// Rust unit test `ingress_layout_c_abi_matches_output_source` pins every
|
|
1274
|
-
// field against output.rs. Slot order mirrors the struct field order.
|
|
1275
|
-
const layoutBuf = new Uint8Array(38 * 4)
|
|
1276
|
-
b.ingressLayout(layoutBuf)
|
|
1277
|
-
const layoutView = new DataView(layoutBuf.buffer, layoutBuf.byteOffset, layoutBuf.byteLength)
|
|
1278
|
-
if (
|
|
1279
|
-
layoutView.getUint32(0, true) !== 0 || // OUT_VERDICT
|
|
1280
|
-
layoutView.getUint32(2 * 4, true) !== 2 || // OUT_STATUS
|
|
1281
|
-
layoutView.getUint32(12 * 4, true) !== 48 || // OUT_DATA_START
|
|
1282
|
-
layoutView.getUint32(13 * 4, true) !== 1 || // FLAG_HAS_COOKIES
|
|
1283
|
-
layoutView.getUint32(28 * 4, true) !== 32 || // HV_COUNT
|
|
1284
|
-
layoutView.getUint32(37 * 4, true) !== 8 // ERR_INTERNAL
|
|
1285
|
-
) {
|
|
1286
|
-
return false
|
|
1287
|
-
}
|
|
1288
|
-
|
|
1289
|
-
// Ingress pipeline C-ABI: with a null (0) inner handle the Rust side returns
|
|
1290
|
-
// 0 immediately and the wrapper throws. This exercises the symbol's ABI (arg
|
|
1291
|
-
// count/types/return) at bind time — a signature drift would surface here
|
|
1292
|
-
// instead of crashing under load. Real frame→output parity is covered by
|
|
1293
|
-
// ffi.test.ts against a live napi instance.
|
|
1294
|
-
try {
|
|
1295
|
-
b.ingressHandlePacked(0, enc.encode('/'), null, new Uint8Array(64))
|
|
1296
|
-
return false // a null handle must throw, not return
|
|
1297
|
-
} catch {
|
|
1298
|
-
// expected: null inner handle → 0 → throw
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// ── New bindings ───────────────────────────────────────────────────
|
|
1302
|
-
const a = enc.encode('a@b.com')
|
|
1303
|
-
const uuid = enc.encode('550e8400-e29b-41d4-a716-446655440000')
|
|
1304
|
-
if (
|
|
1305
|
-
!b.validateEmail(a) ||
|
|
1306
|
-
!b.validateUuid(uuid) ||
|
|
1307
|
-
!b.validateIpv4(enc.encode('192.168.0.1')) ||
|
|
1308
|
-
!b.validateIpv6(enc.encode('2001:db8::1')) ||
|
|
1309
|
-
b.validateEmail(enc.encode('not-an-email')) ||
|
|
1310
|
-
b.validateUuid(enc.encode('not-a-uuid'))
|
|
1311
|
-
) {
|
|
1312
|
-
return false
|
|
1313
|
-
}
|
|
1314
|
-
if (b.jsonSumIds(enc.encode(`[{"id":1},{"id":2}]`)) !== 3n) {
|
|
1315
|
-
return false
|
|
1316
|
-
}
|
|
1317
|
-
// The packed [u8 ok][i64 sum LE] ABI: a legit zero-sum is ok, invalid input throws.
|
|
1318
|
-
if (b.jsonSumIds(enc.encode(`[{"id":0},{"id":0}]`)) !== 0n) {
|
|
1319
|
-
return false
|
|
1320
|
-
}
|
|
1321
|
-
let sumInvalidThrew = false
|
|
1322
|
-
try {
|
|
1323
|
-
b.jsonSumIds(enc.encode('nope'))
|
|
1324
|
-
} catch {
|
|
1325
|
-
sumInvalidThrew = true
|
|
1326
|
-
}
|
|
1327
|
-
if (!sumInvalidThrew) {
|
|
1328
|
-
return false
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
// HMAC RFC 4231 test case 1 (0x0b × 20 key, "Hi There" data).
|
|
1332
|
-
const hmacKey = new Uint8Array(20).fill(0x0b)
|
|
1333
|
-
const hmacData = enc.encode('Hi There')
|
|
1334
|
-
const hmacSig = enc.encode('b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7')
|
|
1335
|
-
const hmacHex = b.hmacSha256(hmacKey, hmacData)
|
|
1336
|
-
if (dec.decode(hmacHex) !== 'b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7') {
|
|
1337
|
-
return false
|
|
1338
|
-
}
|
|
1339
|
-
if (!b.hmacSha256Verify(hmacKey, hmacData, hmacSig)) {
|
|
1340
|
-
return false
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
// Decoders round-trip.
|
|
1344
|
-
const decoded = b.hexDecode(enc.encode('68656c6c6f'))
|
|
1345
|
-
if (
|
|
1346
|
-
dec.decode(decoded) !== 'hello' ||
|
|
1347
|
-
dec.decode(b.urlDecode(enc.encode('a%20b%2Fc'))) !== 'a b/c'
|
|
1348
|
-
) {
|
|
1349
|
-
return false
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
// WebSocket accept key (RFC 6455 sample).
|
|
1353
|
-
if (
|
|
1354
|
-
dec.decode(b.wsAcceptKey(enc.encode('dGhlIHNhbXBsZSBub25jZQ=='))) !==
|
|
1355
|
-
's3pPLMBiTxaQ9kYGzzhZRbK+xOo='
|
|
1356
|
-
) {
|
|
1357
|
-
return false
|
|
1358
|
-
}
|
|
1359
|
-
|
|
1360
|
-
// ETag: strong = 10 bytes, weak = 12 bytes.
|
|
1361
|
-
if (b.etag(SELFTEST_HEX).length !== 10 || b.etag(SELFTEST_HEX, true).length !== 12) {
|
|
1362
|
-
return false
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
// base64.
|
|
1366
|
-
if (dec.decode(b.base64Encode(SELFTEST_HEX)) !== 'aGVsbG8=') {
|
|
1367
|
-
return false
|
|
1368
|
-
}
|
|
1369
|
-
if (dec.decode(b.base64Decode(enc.encode('aGVsbG8='))) !== 'hello') {
|
|
1370
|
-
return false
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// Signed cookie round-trip.
|
|
1374
|
-
const secret = enc.encode('s3cr3t-secret')
|
|
1375
|
-
const signed = b.signCookie(SELFTEST_HEX, secret)
|
|
1376
|
-
const verified = b.verifyCookie(signed, secret)
|
|
1377
|
-
if (verified === null || dec.decode(verified) !== 'hello') {
|
|
1378
|
-
return false
|
|
1379
|
-
}
|
|
1380
|
-
if (b.verifyCookie(enc.encode('tampered.0000'), secret) !== null) {
|
|
1381
|
-
return false
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
// CSRF token round-trip (issued token verifies against the same secret).
|
|
1385
|
-
const csrfTokenBytes = b.csrfToken(secret)
|
|
1386
|
-
if (csrfTokenBytes.length !== 129 || !b.csrfVerify(csrfTokenBytes, secret)) {
|
|
1387
|
-
return false
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// Argon2id round-trip at minimum cost (fast) — full defaults would take ~50ms.
|
|
1391
|
-
const pw = enc.encode('correct horse battery staple')
|
|
1392
|
-
const salt = enc.encode('salty-salt-16b')
|
|
1393
|
-
const phc = b.passwordHash(pw, salt, 8, 1, 1, 16)
|
|
1394
|
-
if (phc.length === 0 || !b.passwordVerify(pw, phc)) {
|
|
1395
|
-
return false
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
// bcrypt round-trip at minimum cost (fast).
|
|
1399
|
-
const bcryptPhc = b.passwordHashBcrypt(pw, 4)
|
|
1400
|
-
if (bcryptPhc.length === 0 || !b.passwordVerifyBcrypt(pw, bcryptPhc)) {
|
|
1401
|
-
return false
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
// PBKDF2-HMAC-SHA256: password="password", salt="salt", c=1, dkLen=32.
|
|
1405
|
-
// The C ABI writes the RAW derived key; hex-encode before comparing.
|
|
1406
|
-
const dk = b.pbkdf2Sha256(enc.encode('password'), enc.encode('salt'), 1, 32)
|
|
1407
|
-
if (
|
|
1408
|
-
dec.decode(b.hexEncode(dk)) !==
|
|
1409
|
-
'120fb6cffcf8b32c43e7225256c4f837a86548c92ccc35480805987cb70be17b'
|
|
1410
|
-
) {
|
|
1411
|
-
return false
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
// AEAD AES-256-GCM round-trip (key 32B, nonce 12B).
|
|
1415
|
-
const aeadKey = new Uint8Array(32).fill(0x42)
|
|
1416
|
-
const nonce = new Uint8Array(12).fill(0x07)
|
|
1417
|
-
const ct = b.aeadEncrypt(aeadKey, nonce, SELFTEST_HEX, 0)
|
|
1418
|
-
const pt = b.aeadDecrypt(aeadKey, nonce, ct, 0)
|
|
1419
|
-
if (pt === null || dec.decode(pt) !== 'hello') {
|
|
1420
|
-
return false
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
// WebSocket frame: text frame, FIN, no mask → first byte 0x81.
|
|
1424
|
-
const frame = b.wsFrameEncode(1, SELFTEST_HEX, false, true)
|
|
1425
|
-
if (frame.length === 0 || frame[0] !== 0x81) {
|
|
1426
|
-
return false
|
|
1427
|
-
}
|
|
1428
1753
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
// ABI returns 0 = real error, so the JS wrapper does NOT grow-retry re-runs
|
|
1450
|
-
// or allocate up to the 64 MiB decompression cap per bad input).
|
|
1451
|
-
let decompressThrew = false
|
|
1452
|
-
try {
|
|
1453
|
-
b.gzipDecompress(enc.encode('not-a-gzip-stream'))
|
|
1454
|
-
} catch {
|
|
1455
|
-
decompressThrew = true
|
|
1456
|
-
}
|
|
1457
|
-
if (!decompressThrew) {
|
|
1458
|
-
return false
|
|
1459
|
-
}
|
|
1460
|
-
decompressThrew = false
|
|
1461
|
-
try {
|
|
1462
|
-
b.brotliDecompress(enc.encode('not-brotli-stream'))
|
|
1463
|
-
} catch {
|
|
1464
|
-
decompressThrew = true
|
|
1465
|
-
}
|
|
1466
|
-
if (!decompressThrew) {
|
|
1467
|
-
return false
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
// Packed parsers (non-empty output). Packed output is LARGER than input (each
|
|
1471
|
-
// component gets a u32 length prefix), so size with the Rust allocator's
|
|
1472
|
-
// conservative upper bound (`input.len() * 9 + 16` in query_parser.rs).
|
|
1473
|
-
const req = enc.encode('GET /a?b=1 HTTP/1.1\r\nHost: example.com\r\n\r\n')
|
|
1474
|
-
const reqOut = new Uint8Array(req.length * 9 + 16)
|
|
1475
|
-
if (b.httpParseRequestPackedInto(req, reqOut) === 0) {
|
|
1476
|
-
return false
|
|
1477
|
-
}
|
|
1478
|
-
const qIn = enc.encode('a=1&b=2')
|
|
1479
|
-
const qOut = new Uint8Array(qIn.length * 9 + 16)
|
|
1480
|
-
if (b.queryParsePackedInto(qIn, qOut) === 0) {
|
|
1481
|
-
return false
|
|
1482
|
-
}
|
|
1483
|
-
const cIn = enc.encode('a=1; b=2')
|
|
1484
|
-
const cOut = new Uint8Array(cIn.length * 9 + 16)
|
|
1485
|
-
if (b.cookieParsePackedInto(cIn, cOut) === 0) {
|
|
1486
|
-
return false
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
// ── Excluded-surface additions ────────────────────────────────────
|
|
1490
|
-
// form parse shares the query core → 2 pairs.
|
|
1491
|
-
const fIn = enc.encode('a=1&b=2')
|
|
1492
|
-
const fOut = new Uint8Array(fIn.length * 9 + 16)
|
|
1493
|
-
if (b.formParsePackedInto(fIn, fOut) === 0 || fOut[0] !== 2) {
|
|
1494
|
-
return false
|
|
1495
|
-
}
|
|
1496
|
-
|
|
1497
|
-
// multipart parse → 1 part named "field".
|
|
1498
|
-
const boundary = enc.encode('----boundary')
|
|
1499
|
-
// Wire format is `--{boundary}` — boundary is `----boundary`, so the body
|
|
1500
|
-
// must open with `------boundary`.
|
|
1501
|
-
const mBody = enc.encode(
|
|
1502
|
-
'------boundary\r\nContent-Disposition: form-data; name="field"\r\n\r\nvalue\r\n------boundary--',
|
|
1503
|
-
)
|
|
1504
|
-
const mOut = b.multipartParsePacked(mBody, boundary)
|
|
1505
|
-
if (mOut[0] !== 1) {
|
|
1506
|
-
return false
|
|
1507
|
-
}
|
|
1508
|
-
// Packed layout: [u32 count][u32 name_len][name]... → name_len at offset 4.
|
|
1509
|
-
const mNameLen = (mOut[4] ?? 0) | ((mOut[5] ?? 0) << 8) | ((mOut[6] ?? 0) << 16) | ((mOut[7] ?? 0) << 24)
|
|
1510
|
-
if (dec.decode(mOut.subarray(8, 8 + mNameLen)) !== 'field') {
|
|
1511
|
-
return false
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
// WS frame decode: encode("hello") → decode → fin=1, opcode=1, payload="hello".
|
|
1515
|
-
const wf = b.wsFrameEncode(1, SELFTEST_HEX, true, true)
|
|
1516
|
-
const wd = b.wsFrameDecodePacked(wf)
|
|
1517
|
-
if (wd === null || wd[0] !== 1 || wd[1] !== 1 || dec.decode(wd.subarray(6)) !== 'hello') {
|
|
1518
|
-
return false
|
|
1519
|
-
}
|
|
1520
|
-
if (b.wsFrameDecodePacked(enc.encode('\x80')) !== null) {
|
|
1521
|
-
return false
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
// JWT sign with ttl=0 (deterministic — no iat/exp), then verify the
|
|
1525
|
-
// signature with the FFI HMAC to prove the binding is real.
|
|
1526
|
-
const jwtSecret = enc.encode('my-secret')
|
|
1527
|
-
const jwt = b.jwtSignBytes(enc.encode('{"sub":"user-1"}'), jwtSecret, 0, 0)
|
|
1528
|
-
const jwtStr = dec.decode(jwt)
|
|
1529
|
-
const segs = jwtStr.split('.')
|
|
1530
|
-
if (segs.length !== 3 || segs[0] === '' || segs[1] === '' || segs[2] === '') {
|
|
1531
|
-
return false
|
|
1532
|
-
}
|
|
1533
|
-
const signingInput = enc.encode(`${segs[0]}.${segs[1]}`)
|
|
1534
|
-
const sigHex = dec.decode(b.hmacSha256(jwtSecret, signingInput))
|
|
1535
|
-
const sigBytes = b.base64Decode(enc.encode(segs[2]), true, false)
|
|
1536
|
-
if (sigBytes === null || dec.decode(b.hexEncode(sigBytes)) !== sigHex) {
|
|
1537
|
-
return false
|
|
1754
|
+
routeCompile(descriptor) {
|
|
1755
|
+
const handle = Number(routeCompileSym(descriptor, lenOrView(descriptor)))
|
|
1756
|
+
if (handle === 0) {
|
|
1757
|
+
throw new Error('route compile: invalid route descriptor')
|
|
1758
|
+
}
|
|
1759
|
+
return handle
|
|
1760
|
+
},
|
|
1761
|
+
routeRun(handle, frame, output) {
|
|
1762
|
+
// Needed-size convention: `0` = real error (malformed frame / panic); a
|
|
1763
|
+
// write larger than `output.length` is the EXACT required size (caller
|
|
1764
|
+
// allocates once and retries) — only a `0` write throws here.
|
|
1765
|
+
const w = Number(routeRunSym(handle, frame, lenOrView(frame), output, lenOrView(output)))
|
|
1766
|
+
if (w === 0) {
|
|
1767
|
+
throw new Error('route run: malformed frame or pipeline error')
|
|
1768
|
+
}
|
|
1769
|
+
return w
|
|
1770
|
+
},
|
|
1771
|
+
routeDestroy(handle) {
|
|
1772
|
+
routeDestroySym(handle)
|
|
1773
|
+
},
|
|
1538
1774
|
}
|
|
1539
|
-
|
|
1540
|
-
return true
|
|
1541
1775
|
}
|