castrum 0.9.4 → 0.9.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +28 -44
- package/Cargo.toml +21 -4
- package/README.md +8 -6
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +4460 -3648
- package/docs/API.md +1 -0
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CASE_STUDY.md +4 -2
- package/docs/ENVIRONMENT.md +9 -2
- package/docs/FFI_BUN_GUIDE.md +166 -9
- package/docs/HOW_TO_ADD_AN_OP.md +34 -0
- package/docs/REPO_MAP.md +16 -5
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +5 -0
- package/package.json +172 -163
- package/rust/crypto/pbkdf2.rs +9 -0
- package/rust/crypto/session.rs +164 -7
- package/rust/ffi/crypto.rs +119 -29
- package/rust/ffi/mod.rs +1 -0
- package/rust/ffi/task.rs +172 -0
- package/rust/ffi/tests.rs +173 -0
- package/rust/http/form.rs +6 -2
- package/rust/http/query_parser.rs +53 -22
- package/rust/ingress/native_route.rs +131 -140
- package/rust/ingress/pipeline.rs +3 -3
- package/rust/ingress/tests.rs +5 -4
- package/rust/json/json_ser.rs +13 -11
- package/rust/lib.rs +1 -0
- package/rust/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -0
- package/rust/util/bytes.rs +238 -51
- package/src/ingress/health.ts +1 -2
- package/src/native/ffi/build/codecs.ts +29 -7
- package/src/native/ffi/build/compress.ts +8 -0
- package/src/native/ffi/build/instances.ts +54 -2
- package/src/native/ffi/build/metrics.ts +23 -7
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +18 -0
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +3 -1
- package/src/native/ffi/types.ts +57 -0
- package/src/native/ffi.ts +85 -14
- package/src/native/loader.ts +53 -3
- package/src/rust-ffi/scalar/http.ts +54 -16
- package/src/rust-ffi/text.ts +25 -3
- package/src/shared/bytes.ts +19 -0
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- package/src/task/runtime.ts +568 -0
package/rust/util/bytes.rs
CHANGED
|
@@ -145,33 +145,55 @@ pub fn decode_percent_at(src: &[u8], i: usize) -> Option<(u8, usize)> {
|
|
|
145
145
|
|
|
146
146
|
// ── Form-component decoding ────────────────────────────────────────
|
|
147
147
|
|
|
148
|
-
/// Decode failure modes for
|
|
148
|
+
/// Decode failure modes for the form-component decoders.
|
|
149
149
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
150
150
|
pub enum FormDecodeError {
|
|
151
|
-
/// Malformed `%XX`
|
|
151
|
+
/// Malformed `%XX` (truncated / non-hex digit) or a decoded byte sequence
|
|
152
|
+
/// that is not valid UTF-8 — exactly what JS's `decodeURIComponent` throws
|
|
153
|
+
/// `URIError` on. Reported by [`decode_form_component_strict_into`]; the
|
|
154
|
+
/// lenient [`decode_form_component_into`] answers with the RAW component
|
|
155
|
+
/// instead (JS's `catch` arm).
|
|
152
156
|
Malformed,
|
|
153
157
|
/// `out` cannot hold the decoded result.
|
|
154
158
|
BufferTooSmall,
|
|
155
159
|
}
|
|
156
160
|
|
|
157
|
-
///
|
|
158
|
-
///
|
|
161
|
+
/// SIMD-accelerated UTF-8 validation (the crate `http::url_codec` already uses,
|
|
162
|
+
/// so the whole HTTP surface validates with the same implementation).
|
|
163
|
+
#[inline]
|
|
164
|
+
fn is_valid_utf8(bytes: &[u8]) -> bool {
|
|
165
|
+
simdutf8::basic::from_utf8(bytes).is_ok()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// THE form-component decoder: `+` → space, `%XX` → byte, bulk-copying plain
|
|
169
|
+
/// runs between them, and validating the result's UTF-8 shape.
|
|
170
|
+
///
|
|
171
|
+
/// This is the single place URL-form decoding happens. Its callers differ only
|
|
172
|
+
/// in their FAILURE ARM:
|
|
159
173
|
///
|
|
160
|
-
///
|
|
161
|
-
///
|
|
162
|
-
///
|
|
163
|
-
///
|
|
164
|
-
///
|
|
165
|
-
///
|
|
174
|
+
/// * [`decode_form_component_into`] — lenient, JS `decodeURIComponent`
|
|
175
|
+
/// semantics: `try { decodeURIComponent(s.replace(/\+/g, " ")) } catch { return s }`.
|
|
176
|
+
/// Used by the packed pair parsers (query/form) and the native route stack,
|
|
177
|
+
/// which must answer exactly like the pure-TS fallback.
|
|
178
|
+
/// * [`decode_form_component_strict_into`] — `Malformed` is a real error, for
|
|
179
|
+
/// callers that map a bad component to a 400 (query → JSON).
|
|
166
180
|
///
|
|
167
|
-
///
|
|
168
|
-
///
|
|
169
|
-
///
|
|
181
|
+
/// Keeping one core means the two can never drift apart again — which is how
|
|
182
|
+
/// the packed parser ended up erroring on input the JS fallback happily
|
|
183
|
+
/// returned raw.
|
|
184
|
+
///
|
|
185
|
+
/// `Malformed` is exactly the set JS rejects: a truncated/non-hex `%XX`, and a
|
|
186
|
+
/// decoded sequence that is not valid UTF-8 (overlong encodings, surrogate
|
|
187
|
+
/// halves and > U+10FFFF fail `simdutf8` and `decodeURIComponent` alike).
|
|
188
|
+
///
|
|
189
|
+
/// The decoded length never exceeds `src.len()`, so a caller that sizes `out`
|
|
190
|
+
/// to the input can only observe [`FormDecodeError::BufferTooSmall`] when it
|
|
191
|
+
/// passed a smaller tail of a larger buffer.
|
|
170
192
|
#[inline]
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
fn decode_form_core(src: &[u8], out: &mut [u8]) -> std::result::Result<usize, FormDecodeError> {
|
|
194
|
+
// Nothing to decode at all (the overwhelming majority of components): a
|
|
195
|
+
// verbatim copy. No UTF-8 check — an undecoded component is passed through
|
|
196
|
+
// byte-for-byte, exactly like the JS fallback's `indexOf` early return.
|
|
175
197
|
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
176
198
|
if out.len() < src.len() {
|
|
177
199
|
return Err(FormDecodeError::BufferTooSmall);
|
|
@@ -187,6 +209,9 @@ pub fn decode_form_component_into(
|
|
|
187
209
|
// input bytes to 1), preserving the existing semantics.
|
|
188
210
|
let mut i = 0usize;
|
|
189
211
|
let mut written = 0usize;
|
|
212
|
+
// ASCII output is trivially valid UTF-8, so the validator is only paid when
|
|
213
|
+
// a high byte actually reaches the result (same discipline as `url_codec`).
|
|
214
|
+
let mut saw_high = false;
|
|
190
215
|
while i < src.len() {
|
|
191
216
|
match memchr::memchr2(b'+', b'%', &src[i..]) {
|
|
192
217
|
Some(rel) => {
|
|
@@ -196,6 +221,7 @@ pub fn decode_form_component_into(
|
|
|
196
221
|
return Err(FormDecodeError::BufferTooSmall);
|
|
197
222
|
}
|
|
198
223
|
out[written..written + run.len()].copy_from_slice(run);
|
|
224
|
+
saw_high |= !run.is_ascii();
|
|
199
225
|
written += run.len();
|
|
200
226
|
i = run_end;
|
|
201
227
|
|
|
@@ -212,6 +238,7 @@ pub fn decode_form_component_into(
|
|
|
212
238
|
return Err(FormDecodeError::BufferTooSmall);
|
|
213
239
|
}
|
|
214
240
|
out[written] = b;
|
|
241
|
+
saw_high |= b >= 0x80;
|
|
215
242
|
written += 1;
|
|
216
243
|
i = next;
|
|
217
244
|
}
|
|
@@ -221,51 +248,136 @@ pub fn decode_form_component_into(
|
|
|
221
248
|
return Err(FormDecodeError::BufferTooSmall);
|
|
222
249
|
}
|
|
223
250
|
out[written..written + run.len()].copy_from_slice(run);
|
|
251
|
+
saw_high |= !run.is_ascii();
|
|
224
252
|
written += run.len();
|
|
225
253
|
i = src.len();
|
|
226
254
|
}
|
|
227
255
|
}
|
|
228
256
|
}
|
|
257
|
+
if saw_high && !is_valid_utf8(&out[..written]) {
|
|
258
|
+
return Err(FormDecodeError::Malformed);
|
|
259
|
+
}
|
|
229
260
|
Ok(written)
|
|
230
261
|
}
|
|
231
262
|
|
|
232
|
-
///
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
263
|
+
/// Copy `src` verbatim into `out` — the raw fallback of the lenient contract.
|
|
264
|
+
#[inline]
|
|
265
|
+
fn write_raw_component(src: &[u8], out: &mut [u8]) -> std::result::Result<usize, FormDecodeError> {
|
|
266
|
+
if out.len() < src.len() {
|
|
267
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
268
|
+
}
|
|
269
|
+
out[..src.len()].copy_from_slice(src);
|
|
270
|
+
Ok(src.len())
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// URL-decode a form component (`+` → space, `%XX` → byte) into `out` with
|
|
274
|
+
/// **JS `decodeURIComponent` fallback semantics**, returning bytes written.
|
|
238
275
|
///
|
|
239
|
-
///
|
|
240
|
-
///
|
|
276
|
+
/// Per COMPONENT (name and value decode independently):
|
|
277
|
+
///
|
|
278
|
+
/// 1. no `%` and no `+` → copied verbatim;
|
|
279
|
+
/// 2. otherwise `+` → space and `%XX` → byte;
|
|
280
|
+
/// 3. a malformed escape (truncated or non-hex) → the WHOLE component is
|
|
281
|
+
/// returned RAW — note `+` stays `+`, because JS's `catch` returns the
|
|
282
|
+
/// string it was given, before the `+` replacement;
|
|
283
|
+
/// 4. a decoded sequence that is not valid UTF-8 → the whole component RAW.
|
|
284
|
+
///
|
|
285
|
+
/// That is `try { decodeURIComponent(s.replace(/\+/g, " ")) } catch { return s }`
|
|
286
|
+
/// — the pure-TS fallback swallows the `URIError` and the native path must too,
|
|
287
|
+
/// or a single bad escape turns a public request into a 500 (and a divergent
|
|
288
|
+
/// parse) instead of a raw field.
|
|
241
289
|
#[inline]
|
|
242
|
-
pub fn
|
|
290
|
+
pub fn decode_form_component_into(
|
|
291
|
+
src: &[u8],
|
|
292
|
+
out: &mut [u8],
|
|
293
|
+
) -> std::result::Result<usize, FormDecodeError> {
|
|
294
|
+
match decode_form_core(src, out) {
|
|
295
|
+
Ok(written) => Ok(written),
|
|
296
|
+
Err(FormDecodeError::Malformed) => write_raw_component(src, out),
|
|
297
|
+
Err(e) => Err(e),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Compute the decoded length of `src` WITHOUT writing — the exact-size pass for
|
|
302
|
+
/// the C-ABI "needed size" convention (run ONCE on a buffer miss, so the caller
|
|
303
|
+
/// can `growExact` and retry instead of a doubling loop).
|
|
304
|
+
///
|
|
305
|
+
/// Reports [`FormDecodeError::Malformed`] exactly when the writer would (the
|
|
306
|
+
/// raw fallback reports `src.len()`), so the reported size and the written size
|
|
307
|
+
/// can never disagree — a mismatch would make the caller's grow-and-retry loop
|
|
308
|
+
/// spin forever.
|
|
309
|
+
///
|
|
310
|
+
/// One conservative case is deliberate: when the decoded output contains a
|
|
311
|
+
/// non-ASCII byte, the result MAY be invalid UTF-8 and the writer would then
|
|
312
|
+
/// emit the raw component (longer than the decoded form), so `src.len()` is
|
|
313
|
+
/// reported. Over-reporting only costs the caller a few buffer bytes (the C fn
|
|
314
|
+
/// still returns the true written length); under-reporting would hang.
|
|
315
|
+
#[inline]
|
|
316
|
+
pub fn decode_form_component_len(src: &[u8]) -> usize {
|
|
243
317
|
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
244
|
-
return
|
|
318
|
+
return src.len();
|
|
245
319
|
}
|
|
246
320
|
let mut i = 0usize;
|
|
247
321
|
let mut written = 0usize;
|
|
322
|
+
let mut saw_high = false;
|
|
248
323
|
while i < src.len() {
|
|
249
324
|
match memchr::memchr2(b'+', b'%', &src[i..]) {
|
|
250
325
|
Some(rel) => {
|
|
326
|
+
let run_end = i + rel;
|
|
251
327
|
written += rel;
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
328
|
+
saw_high |= !src[i..run_end].is_ascii();
|
|
329
|
+
i = run_end;
|
|
330
|
+
match src[i] {
|
|
331
|
+
b'+' => {
|
|
332
|
+
written += 1;
|
|
333
|
+
i += 1;
|
|
334
|
+
}
|
|
335
|
+
_ => match decode_percent_at(src, i) {
|
|
336
|
+
Some((byte, next)) => {
|
|
337
|
+
written += 1;
|
|
338
|
+
saw_high |= byte >= 0x80;
|
|
339
|
+
i = next;
|
|
340
|
+
}
|
|
341
|
+
// Malformed → the writer emits the raw component, so
|
|
342
|
+
// the raw length is the exact answer here.
|
|
343
|
+
None => return src.len(),
|
|
344
|
+
},
|
|
345
|
+
}
|
|
261
346
|
}
|
|
262
347
|
None => {
|
|
263
348
|
written += src.len() - i;
|
|
349
|
+
saw_high |= !src[i..].is_ascii();
|
|
264
350
|
i = src.len();
|
|
265
351
|
}
|
|
266
352
|
}
|
|
267
353
|
}
|
|
268
|
-
|
|
354
|
+
if saw_high {
|
|
355
|
+
src.len()
|
|
356
|
+
} else {
|
|
357
|
+
written
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Decode a form component into a reusable scratch buffer, returning the
|
|
362
|
+
/// decoded bytes — or `src` itself when nothing needed decoding. The shared
|
|
363
|
+
/// entry point for Vec-based callers (the native route stack); the returned
|
|
364
|
+
/// slice borrows either `src` or `scratch`, so it is valid only until the next
|
|
365
|
+
/// call with the same scratch.
|
|
366
|
+
#[inline]
|
|
367
|
+
pub fn decode_form_component_scratch<'a>(src: &'a [u8], scratch: &'a mut Vec<u8>) -> &'a [u8] {
|
|
368
|
+
// Fast path: nothing to decode (the overwhelming majority of segments).
|
|
369
|
+
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
370
|
+
return src;
|
|
371
|
+
}
|
|
372
|
+
if scratch.len() < src.len() {
|
|
373
|
+
scratch.resize(src.len(), 0);
|
|
374
|
+
}
|
|
375
|
+
match decode_form_component_into(src, scratch.as_mut_slice()) {
|
|
376
|
+
Ok(n) => &scratch[..n],
|
|
377
|
+
// Unreachable: the buffer is input-sized, and the decoded length never
|
|
378
|
+
// exceeds the input.
|
|
379
|
+
Err(_) => src,
|
|
380
|
+
}
|
|
269
381
|
}
|
|
270
382
|
|
|
271
383
|
// ── Whitespace + cookie splitting ──────────────────────────────────
|
|
@@ -350,23 +462,98 @@ mod tests {
|
|
|
350
462
|
assert_eq!(decode(b"%41%42"), b"AB");
|
|
351
463
|
assert_eq!(decode(b"q=%E2%9C%93"), b"q=\xE2\x9C\x93");
|
|
352
464
|
assert_eq!(decode(b"%2B"), b"+");
|
|
465
|
+
// A NUL is a legal decode target (U+0000), not an error.
|
|
466
|
+
assert_eq!(decode(b"%00"), b"\x00");
|
|
467
|
+
assert_eq!(decode(b"%2f"), b"/");
|
|
353
468
|
}
|
|
354
469
|
|
|
355
470
|
#[test]
|
|
356
|
-
fn
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
471
|
+
fn decode_form_decodeuri_semantics_table() {
|
|
472
|
+
// The contract is `try { decodeURIComponent(s.replace(/\+/g," ")) }
|
|
473
|
+
// catch { return s }` — verified against Bun's `decodeURIComponent`.
|
|
474
|
+
let cases: &[(&[u8], &[u8])] = &[
|
|
475
|
+
// (input, JS result)
|
|
476
|
+
(b"a=%C3", b"a=%C3"), // truncated multibyte → URIError → raw
|
|
477
|
+
(b"a=%2", b"a=%2"), // truncated escape → raw
|
|
478
|
+
(b"a=%", b"a=%"), // dangling % → raw
|
|
479
|
+
(b"a=%2G", b"a=%2G"), // non-hex digit → raw
|
|
480
|
+
(b"a=%FF", b"a=%FF"), // invalid UTF-8 byte → raw
|
|
481
|
+
(b"a=%ED%A0%80", b"a=%ED%A0%80"), // surrogate half → raw
|
|
482
|
+
(b"a=%C0%80", b"a=%C0%80"), // overlong encoding → raw
|
|
483
|
+
(b"a=%F4%90%80%80", b"a=%F4%90%80%80"), // > U+10FFFF → raw
|
|
484
|
+
(b"bad=%ZZ", b"bad=%ZZ"), // non-hex → raw
|
|
485
|
+
(b"100%", b"100%"), // trailing % → raw
|
|
486
|
+
// The whole component goes raw — including a '+' that the decode
|
|
487
|
+
// would otherwise have turned into a space (the JS catch returns
|
|
488
|
+
// the string it was given, before the replace).
|
|
489
|
+
(b"a+b=%ZZ", b"a+b=%ZZ"),
|
|
490
|
+
(b"a+b=%20", b"a b= "), // valid → decoded
|
|
491
|
+
(b"%C3%A9", b"\xC3\xA9"), // valid multibyte → decoded
|
|
492
|
+
];
|
|
493
|
+
for (input, expected) in cases {
|
|
494
|
+
assert_eq!(
|
|
495
|
+
decode(input).as_slice(),
|
|
496
|
+
*expected,
|
|
497
|
+
"lenient decode of {:?}",
|
|
498
|
+
core::str::from_utf8(input).unwrap_or("<non-utf8>")
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#[test]
|
|
504
|
+
fn decode_form_needed_size_matches_written_size() {
|
|
505
|
+
// The C-ABI "needed size" pass must equal what the writer produces for
|
|
506
|
+
// EVERY input — a mismatch would spin the caller's grow-and-retry loop.
|
|
507
|
+
// It may only over-report (never under-report) when the decoded form
|
|
508
|
+
// could be invalid UTF-8.
|
|
509
|
+
let cases: &[&[u8]] = &[
|
|
510
|
+
b"",
|
|
511
|
+
b"a",
|
|
512
|
+
b"a=1&b=2",
|
|
513
|
+
b"%ZZ",
|
|
514
|
+
b"a=%C3",
|
|
515
|
+
b"a=%FF",
|
|
516
|
+
b"a=%ED%A0%80",
|
|
517
|
+
b"q=hello+world",
|
|
518
|
+
b"u=%E2%9C%93",
|
|
519
|
+
b"\xFF\xFE", // non-UTF-8 passthrough
|
|
520
|
+
b"a=%E2%9C%93&b%ZZ=1",
|
|
521
|
+
b"x%",
|
|
522
|
+
];
|
|
523
|
+
for src in cases {
|
|
524
|
+
let mut out = vec![0u8; src.len() + 16];
|
|
525
|
+
let written = decode_form_component_into(src, &mut out).unwrap();
|
|
526
|
+
let reported = decode_form_component_len(src);
|
|
527
|
+
assert!(
|
|
528
|
+
reported >= written,
|
|
529
|
+
"reported {reported} < written {written} for {:?}",
|
|
530
|
+
core::str::from_utf8(src).unwrap_or("<non-utf8>")
|
|
531
|
+
);
|
|
532
|
+
// An exact-size buffer must succeed and agree with the report.
|
|
533
|
+
let mut exact = vec![0u8; reported];
|
|
534
|
+
assert_eq!(
|
|
535
|
+
decode_form_component_into(src, &mut exact).unwrap(),
|
|
536
|
+
written,
|
|
537
|
+
"exact-size decode disagreed for {:?}",
|
|
538
|
+
core::str::from_utf8(src).unwrap_or("<non-utf8>")
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[test]
|
|
544
|
+
fn decode_form_scratch_matches_slice_decoder() {
|
|
545
|
+
let mut scratch: Vec<u8> = Vec::new();
|
|
546
|
+
for input in [
|
|
547
|
+
&b"plain"[..],
|
|
548
|
+
b"a+b",
|
|
549
|
+
b"a=%20b",
|
|
550
|
+
b"%ZZ",
|
|
551
|
+
b"%C3",
|
|
552
|
+
b"a=%E2%9C%93",
|
|
553
|
+
] {
|
|
554
|
+
let via_scratch = decode_form_component_scratch(input, &mut scratch).to_vec();
|
|
555
|
+
assert_eq!(via_scratch.as_slice(), decode(input).as_slice());
|
|
556
|
+
}
|
|
370
557
|
}
|
|
371
558
|
|
|
372
559
|
#[test]
|
package/src/ingress/health.ts
CHANGED
|
@@ -49,10 +49,9 @@ export function readinessHandler(check?: () => boolean | Promise<boolean>) {
|
|
|
49
49
|
return ok
|
|
50
50
|
? jsonResponse(200, { status: 'ready' })
|
|
51
51
|
: jsonResponse(503, { status: 'not_ready' })
|
|
52
|
-
} catch (
|
|
52
|
+
} catch (_err) {
|
|
53
53
|
return jsonResponse(503, {
|
|
54
54
|
status: 'not_ready',
|
|
55
|
-
error: err instanceof Error ? err.message : String(err),
|
|
56
55
|
})
|
|
57
56
|
}
|
|
58
57
|
}
|
|
@@ -9,7 +9,7 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
|
|
|
9
9
|
import { SELFTEST_HEX, SELFTEST_JSON } from '../constants'
|
|
10
10
|
import type { BunFFI, Raw3, Raw4, Raw5, Raw6, Raw8, Raw9, Raw10, RawCStr } from '../types'
|
|
11
11
|
import type { BuildCtx } from './util'
|
|
12
|
-
import { allocOut, argon2PhcLength, cstr, flag, growExact, writeOrThrow } from './util'
|
|
12
|
+
import { allocOut, argon2PhcLength, cstr, flag, growExact, hasNul, writeOrThrow } from './util'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Build the codec/crypto/auth methods of the BunFFI surface. `ctx` is
|
|
@@ -182,10 +182,14 @@ export function buildCodecs(
|
|
|
182
182
|
},
|
|
183
183
|
urlEncodeInto,
|
|
184
184
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
185
|
+
// Verdict guards: the `cstring` form would truncate at an embedded NUL, so
|
|
186
|
+
// `"a@b.com\0<script>"` would be reported VALID — a verdict callers act on.
|
|
187
|
+
// No address / UUID / IP contains U+0000, so short-circuit without an FFI
|
|
188
|
+
// call (one `indexOf` scan, far cheaper than the encode fallback).
|
|
189
|
+
validateEmail: (input) => !hasNul(input) && Number(validateEmail(input)) === 1,
|
|
190
|
+
validateUuid: (input) => !hasNul(input) && Number(validateUuid(input)) === 1,
|
|
191
|
+
validateIpv4: (input) => !hasNul(input) && Number(validateIpv4(input)) === 1,
|
|
192
|
+
validateIpv6: (input) => !hasNul(input) && Number(validateIpv6(input)) === 1,
|
|
189
193
|
// Byte-input validators: `(ptr,len)` — zero transcode (no CString decode
|
|
190
194
|
// + engine re-encode like the cstring-ARG string forms above).
|
|
191
195
|
validateEmailBytes: (input) => Number(oneArg(validateEmailBytes, input)) === 1,
|
|
@@ -212,12 +216,30 @@ export function buildCodecs(
|
|
|
212
216
|
return w
|
|
213
217
|
},
|
|
214
218
|
// Zero-copy text path: cstring ARG in (engine-transcoded), cstring return
|
|
215
|
-
// out (engine-cloned) — the JS side does zero encode AND zero decode.
|
|
216
|
-
|
|
219
|
+
// out (engine-cloned) — the JS side does zero encode AND zero decode. A NUL
|
|
220
|
+
// would truncate the INPUT, so that case routes to the `(ptr,len)` sibling
|
|
221
|
+
// (exact length) instead of returning a silently shortened string.
|
|
222
|
+
regexEscapeStr: (input) => {
|
|
223
|
+
if (!hasNul(input)) return regexEscapeStrRaw(input)
|
|
224
|
+
const bytes = encodeUtf8(input)
|
|
225
|
+
const escaped = growExact(
|
|
226
|
+
(out) => Number(regexEscapeRaw(bytes, lenOrView(bytes), out, lenOrView(out))),
|
|
227
|
+
16,
|
|
228
|
+
1 << 20,
|
|
229
|
+
'regex escape: output buffer too small',
|
|
230
|
+
)
|
|
231
|
+
return decodeUtf8(escaped)
|
|
232
|
+
},
|
|
217
233
|
hexValidateBatchStr(ids, width, output) {
|
|
218
234
|
if (width === 0 || width > 4096) {
|
|
219
235
|
throw new Error('hex validate batch: width must be 1..=4096')
|
|
220
236
|
}
|
|
237
|
+
// A NUL in the joined ids would truncate the line list, so a bad id could
|
|
238
|
+
// pass as its own prefix. Fall back to the byte form (exact length).
|
|
239
|
+
if (hasNul(ids)) {
|
|
240
|
+
const bytes = encodeUtf8(ids)
|
|
241
|
+
return Number(hexBatchRaw(bytes, lenOrView(bytes), width >>> 0, output, lenOrView(output)))
|
|
242
|
+
}
|
|
221
243
|
const w = Number(hexBatchStrRaw(ids, width >>> 0, output, lenOrView(output)))
|
|
222
244
|
return w
|
|
223
245
|
},
|
|
@@ -117,6 +117,14 @@ export function buildCompress(
|
|
|
117
117
|
}
|
|
118
118
|
return w
|
|
119
119
|
},
|
|
120
|
+
/**
|
|
121
|
+
* gzip ISIZE probe → uncompressed size for a single-member stream, or `0`
|
|
122
|
+
* when unknown. Used to PRE-SIZE a destination buffer for the zero-copy
|
|
123
|
+
* task path (one pass, no intermediate buffer, no retry in the common case).
|
|
124
|
+
*/
|
|
125
|
+
gzipIsize(data) {
|
|
126
|
+
return Number(gzipIsize(data, lenOrView(data)))
|
|
127
|
+
},
|
|
120
128
|
brotliCompress(data, quality = 5) {
|
|
121
129
|
// Same cap rationale as gzipCompress (streaming core); growExact for
|
|
122
130
|
// incompressible data.
|
|
@@ -10,7 +10,7 @@ import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
|
|
|
10
10
|
import { EMPTY_VIEW } from '../constants'
|
|
11
11
|
import type { BunFFI, Raw2, Raw3, Raw4, Raw5, Raw6, Raw7, Raw8, Raw12, RawCStr } from '../types'
|
|
12
12
|
import type { BuildCtx } from './util'
|
|
13
|
-
import { cstr, growExact, unpackRateCheck } from './util'
|
|
13
|
+
import { cstr, growExact, hasNul, unpackRateCheck } from './util'
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Build the opaque-handle methods of the BunFFI surface. `ctx` is destructured
|
|
@@ -41,6 +41,8 @@ export function buildInstances(
|
|
|
41
41
|
const cookieValidateSym = sym.castrum_cookie_validate as (...a: unknown[]) => number | bigint
|
|
42
42
|
const sessionSealSym = sym.castrum_session_seal as (...a: unknown[]) => string | null
|
|
43
43
|
const sessionOpenSym = sym.castrum_session_open as Raw4
|
|
44
|
+
const sessionSealBytesSym = sym.castrum_session_seal_bytes as RawCStr
|
|
45
|
+
const sessionOpenBytesSym = sym.castrum_session_open_bytes as Raw6
|
|
44
46
|
const rateLimiterCheckRaw = sym.castrum_rate_limiter_check as Raw5
|
|
45
47
|
const rateLimiterCheckKeyRaw = sym.castrum_rate_limiter_check_key as Raw5
|
|
46
48
|
const jwtSignBytes = sym.castrum_jwt_sign_bytes as RawCStr
|
|
@@ -144,10 +146,15 @@ export function buildInstances(
|
|
|
144
146
|
return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
|
|
145
147
|
},
|
|
146
148
|
queryValidate(inner, qs) {
|
|
147
|
-
// `qs` is a cstring ARG — engine-transcoded (zero JS encode).
|
|
149
|
+
// `qs` is a cstring ARG — engine-transcoded (zero JS encode). A raw NUL is
|
|
150
|
+
// not a valid query byte, and it would TRUNCATE the arg so only a prefix
|
|
151
|
+
// got validated: a gate that says "this query passed" must not be foolable.
|
|
152
|
+
if (hasNul(qs)) return false
|
|
148
153
|
return Number(queryValidateSym(inner, qs)) === 1
|
|
149
154
|
},
|
|
150
155
|
cookieValidate(inner, header) {
|
|
156
|
+
// Same contract as `queryValidate`: a NUL would validate only a prefix.
|
|
157
|
+
if (hasNul(header)) return false
|
|
151
158
|
return Number(cookieValidateSym(inner, header)) === 1
|
|
152
159
|
},
|
|
153
160
|
sessionSeal(id, dataJson, expSecs, secret) {
|
|
@@ -159,6 +166,33 @@ export function buildInstances(
|
|
|
159
166
|
const w = Number(sessionOpenSym(token, secret, output, lenOrView(output)))
|
|
160
167
|
return w
|
|
161
168
|
},
|
|
169
|
+
sessionSealBytes(id, dataJson, expSecs, secret) {
|
|
170
|
+
// Byte-arg form: exact lengths, no engine transcode, no NUL truncation.
|
|
171
|
+
// The parity with `sessionSeal` is asserted in the bind-time self-test
|
|
172
|
+
// below (same core ⇒ byte-identical token for identical inputs).
|
|
173
|
+
return sessionSealBytesSym(
|
|
174
|
+
id,
|
|
175
|
+
lenOrView(id),
|
|
176
|
+
dataJson,
|
|
177
|
+
lenOrView(dataJson),
|
|
178
|
+
BigInt(expSecs),
|
|
179
|
+
secret,
|
|
180
|
+
lenOrView(secret),
|
|
181
|
+
)
|
|
182
|
+
},
|
|
183
|
+
sessionOpenBytes(token, secret, output) {
|
|
184
|
+
// Needed-size convention; 0 = bad signature / malformed.
|
|
185
|
+
return Number(
|
|
186
|
+
sessionOpenBytesSym(
|
|
187
|
+
token,
|
|
188
|
+
lenOrView(token),
|
|
189
|
+
secret,
|
|
190
|
+
lenOrView(secret),
|
|
191
|
+
output,
|
|
192
|
+
lenOrView(output),
|
|
193
|
+
),
|
|
194
|
+
)
|
|
195
|
+
},
|
|
162
196
|
rateLimiterCheck(inner, key, nowMs) {
|
|
163
197
|
// Packed [u8 allowed][u32 remaining LE][i64 reset_ms LE] (13 bytes).
|
|
164
198
|
// Reused scratch + cached DataView (no per-call allocs). `key` is a
|
|
@@ -618,6 +652,24 @@ export function selfTestInstances(b: BunFFI): boolean {
|
|
|
618
652
|
if (w <= 13 || out[0] !== 1) return false
|
|
619
653
|
// bad signature → 0
|
|
620
654
|
if (b.sessionOpen(tok, 'wrong', out) !== 0) return false
|
|
655
|
+
|
|
656
|
+
// Byte-arg siblings. Both forms share ONE core, so the sealed token must be
|
|
657
|
+
// byte-identical — the strongest available parity check between the two.
|
|
658
|
+
const enc = new TextEncoder()
|
|
659
|
+
const tokB = b.sessionSealBytes(
|
|
660
|
+
enc.encode('sess-9'),
|
|
661
|
+
enc.encode('{"n":1}'),
|
|
662
|
+
1234567,
|
|
663
|
+
enc.encode('sekrit'),
|
|
664
|
+
)
|
|
665
|
+
if (tokB === null || tokB !== tok) return false
|
|
666
|
+
const outB = new Uint8Array(256)
|
|
667
|
+
const wB = b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB)
|
|
668
|
+
if (wB !== w || outB[0] !== 1) return false
|
|
669
|
+
// Bad signature → 0; a too-small buffer reports the EXACT size, not 0.
|
|
670
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('wrong'), outB) !== 0) return false
|
|
671
|
+
if (b.sessionOpenBytes(enc.encode(tokB), enc.encode('sekrit'), outB.subarray(0, 4)) !== wB)
|
|
672
|
+
return false
|
|
621
673
|
}
|
|
622
674
|
|
|
623
675
|
// rateLimiterCheckKey: null (0) handle → throw (ABI exercise).
|
|
@@ -9,6 +9,18 @@
|
|
|
9
9
|
import { decodeUtf8, encodeUtf8 } from '../../../shared/codec'
|
|
10
10
|
import type { BunFFI, Raw0, Raw3, Raw4, Raw5 } from '../types'
|
|
11
11
|
import type { BuildCtx } from './util'
|
|
12
|
+
import { hasNul } from './util'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Declare-time NUL guard: a metric NAME or label-key set containing `U+0000`
|
|
16
|
+
* would be truncated by the `cstring` arg, silently colliding two series. This
|
|
17
|
+
* runs once at declaration, so failing fast costs nothing on the hot path.
|
|
18
|
+
*/
|
|
19
|
+
function throwIfNul(value: string, what: string): void {
|
|
20
|
+
if (hasNul(value)) {
|
|
21
|
+
throw new Error(`metrics: ${what} must not contain a NUL character (U+0000)`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
|
|
13
25
|
/**
|
|
14
26
|
* Sentinel returned by the declare fns (`u32::MAX`) on invalid input — fits
|
|
@@ -59,12 +71,18 @@ export function buildMetrics(
|
|
|
59
71
|
return h
|
|
60
72
|
},
|
|
61
73
|
metricsCounter(handle, name, labelKeys) {
|
|
74
|
+
throwIfNul(name, 'counter name')
|
|
75
|
+
throwIfNul(labelKeys, 'counter label keys')
|
|
62
76
|
return declareOrThrow(metricsCounterRaw(handle, name, labelKeys), `counter "${name}"`)
|
|
63
77
|
},
|
|
64
78
|
metricsGauge(handle, name, labelKeys) {
|
|
79
|
+
throwIfNul(name, 'gauge name')
|
|
80
|
+
throwIfNul(labelKeys, 'gauge label keys')
|
|
65
81
|
return declareOrThrow(metricsGaugeRaw(handle, name, labelKeys), `gauge "${name}"`)
|
|
66
82
|
},
|
|
67
83
|
metricsHistogram(handle, name, labelKeys, bucketsCsv) {
|
|
84
|
+
throwIfNul(name, 'histogram name')
|
|
85
|
+
throwIfNul(labelKeys, 'histogram label keys')
|
|
68
86
|
return declareOrThrow(
|
|
69
87
|
metricsHistogramRaw(handle, name, labelKeys, bucketsCsv),
|
|
70
88
|
`histogram "${name}"`,
|
|
@@ -79,9 +97,14 @@ export function buildMetrics(
|
|
|
79
97
|
},
|
|
80
98
|
metricsRecordStr(handle, series, values, amount) {
|
|
81
99
|
// `values` is the JOINED `\x1f` string — engine-transcoded (zero encode).
|
|
100
|
+
// Label values come from user data, so a NUL would truncate the join and
|
|
101
|
+
// silently record the WRONG series (the native side rejects a NUL value,
|
|
102
|
+
// but only if it ever sees it). Fail loudly and say why.
|
|
103
|
+
throwIfNul(values, 'label values')
|
|
82
104
|
return Number(metricsRecordStrRaw(handle, series, values, amount)) === 1
|
|
83
105
|
},
|
|
84
106
|
metricsGaugeSetStr(handle, series, values, value) {
|
|
107
|
+
throwIfNul(values, 'label values')
|
|
85
108
|
return Number(metricsGaugeSetStrRaw(handle, series, values, value)) === 1
|
|
86
109
|
},
|
|
87
110
|
metricsRender(handle, output) {
|
|
@@ -136,23 +159,19 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
136
159
|
return false
|
|
137
160
|
}
|
|
138
161
|
|
|
139
|
-
console.error('CK: counter')
|
|
140
162
|
const gauge = b.metricsGauge(handle, 'ct_selftest_depth', 'q')
|
|
141
163
|
if (gauge === METRICS_DECLARE_ERR) return false
|
|
142
164
|
if (!b.metricsGaugeSet(handle, gauge, encodeUtf8('jobs'), 4)) return false
|
|
143
165
|
if (!b.metricsGaugeSetStr(handle, gauge, 'io', 6)) return false
|
|
144
166
|
|
|
145
|
-
console.error('CK: gauge')
|
|
146
167
|
const hist = b.metricsHistogram(handle, 'ct_selftest_latency', '', '0.1,0.5')
|
|
147
168
|
if (hist === METRICS_DECLARE_ERR) return false
|
|
148
169
|
if (!b.metricsRecord(handle, hist, EMPTY, 0.25)) return false
|
|
149
170
|
if (b.metricsRecord(handle, hist, EMPTY, -1)) return false // negatives rejected
|
|
150
171
|
|
|
151
|
-
console.error('CK: hist')
|
|
152
172
|
// Arity mismatch must fail safely.
|
|
153
173
|
if (b.metricsRecord(handle, counter, encodeUtf8('/a'), 1)) return false
|
|
154
174
|
|
|
155
|
-
console.error('CK: arity')
|
|
156
175
|
// Render: probe with a small buffer → exact required size; then render.
|
|
157
176
|
const probe = new Uint8Array(16)
|
|
158
177
|
const needed = b.metricsRender(handle, probe)
|
|
@@ -172,7 +191,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
172
191
|
return false
|
|
173
192
|
}
|
|
174
193
|
|
|
175
|
-
console.error('CK: render')
|
|
176
194
|
// Snapshot: packed dump starts with version=1 + familyCount>=3.
|
|
177
195
|
{
|
|
178
196
|
const probe2 = new Uint8Array(16)
|
|
@@ -185,7 +203,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
185
203
|
if (sv.getUint32(4, true) < 3) return false
|
|
186
204
|
}
|
|
187
205
|
|
|
188
|
-
console.error('CK: snapshot')
|
|
189
206
|
// Batch: two entries in ONE crossing.
|
|
190
207
|
{
|
|
191
208
|
const c2 = b.metricsCounter(handle, 'ct_selftest_batch', 'k')
|
|
@@ -206,7 +223,6 @@ export function selfTestMetrics(b: BunFFI): boolean {
|
|
|
206
223
|
}
|
|
207
224
|
}
|
|
208
225
|
|
|
209
|
-
console.error('CK: batch')
|
|
210
226
|
// A null handle must throw on declare (never dereference freed state).
|
|
211
227
|
try {
|
|
212
228
|
b.metricsCounter(0, 'ct_selftest_x', '')
|