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.
Files changed (61) hide show
  1. package/Cargo.lock +28 -44
  2. package/Cargo.toml +21 -4
  3. package/README.md +8 -6
  4. package/castrum.darwin-arm64.node +0 -0
  5. package/castrum.darwin-x64.node +0 -0
  6. package/castrum.linux-arm64-gnu.node +0 -0
  7. package/castrum.linux-arm64-musl.node +0 -0
  8. package/castrum.linux-x64-gnu.node +0 -0
  9. package/castrum.linux-x64-musl.node +0 -0
  10. package/castrum.linux-x64-v3-gnu.node +0 -0
  11. package/castrum.win32-x64-msvc.node +0 -0
  12. package/dist/index.d.ts +164 -0
  13. package/dist/index.js +4460 -3648
  14. package/docs/API.md +1 -0
  15. package/docs/ARCHITECTURE.md +1 -1
  16. package/docs/CASE_STUDY.md +4 -2
  17. package/docs/ENVIRONMENT.md +9 -2
  18. package/docs/FFI_BUN_GUIDE.md +166 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +34 -0
  20. package/docs/REPO_MAP.md +16 -5
  21. package/docs/RND-CONCURRENCY.md +772 -0
  22. package/docs/bun-builtins-decision-matrix.md +4 -1
  23. package/index.ts +5 -0
  24. package/package.json +172 -163
  25. package/rust/crypto/pbkdf2.rs +9 -0
  26. package/rust/crypto/session.rs +164 -7
  27. package/rust/ffi/crypto.rs +119 -29
  28. package/rust/ffi/mod.rs +1 -0
  29. package/rust/ffi/task.rs +172 -0
  30. package/rust/ffi/tests.rs +173 -0
  31. package/rust/http/form.rs +6 -2
  32. package/rust/http/query_parser.rs +53 -22
  33. package/rust/ingress/native_route.rs +131 -140
  34. package/rust/ingress/pipeline.rs +3 -3
  35. package/rust/ingress/tests.rs +5 -4
  36. package/rust/json/json_ser.rs +13 -11
  37. package/rust/lib.rs +1 -0
  38. package/rust/task/completion.rs +119 -0
  39. package/rust/task/mod.rs +65 -0
  40. package/rust/task/ops.rs +503 -0
  41. package/rust/task/runtime.rs +220 -0
  42. package/rust/task/tests.rs +231 -0
  43. package/rust/util/bytes.rs +238 -51
  44. package/src/ingress/health.ts +1 -2
  45. package/src/native/ffi/build/codecs.ts +29 -7
  46. package/src/native/ffi/build/compress.ts +8 -0
  47. package/src/native/ffi/build/instances.ts +54 -2
  48. package/src/native/ffi/build/metrics.ts +23 -7
  49. package/src/native/ffi/build/task.ts +79 -0
  50. package/src/native/ffi/build/util.ts +18 -0
  51. package/src/native/ffi/build.ts +2 -0
  52. package/src/native/ffi/selftest.ts +3 -1
  53. package/src/native/ffi/types.ts +57 -0
  54. package/src/native/ffi.ts +85 -14
  55. package/src/native/loader.ts +53 -3
  56. package/src/rust-ffi/scalar/http.ts +54 -16
  57. package/src/rust-ffi/text.ts +25 -3
  58. package/src/shared/bytes.ts +19 -0
  59. package/src/task/index.ts +24 -0
  60. package/src/task/op.ts +171 -0
  61. package/src/task/runtime.ts +568 -0
@@ -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 [`decode_form_component_into`].
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` sequence (truncated or a non-hex digit).
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
- /// URL-decode a form component (`+` → space, `%XX` → byte) into `out`,
158
- /// returning the number of bytes written.
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
- /// The decoded length never exceeds `src.len()` (each input byte maps to at
161
- /// most one output byte). Callers that pre-size `out` to `src.len()` can never
162
- /// observe [`FormDecodeError::BufferTooSmall`]; callers that pass the remaining
163
- /// tail of a larger buffer get a precise capacity check against the ACTUAL
164
- /// decoded length (a `%XX` sequence shrinks 3 input bytes to 1, so a buffer
165
- /// sized to the decoded form succeeds).
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
- /// Single shared implementation for `query_parser::write_decoded_form_component`
168
- /// (length-prefixed slice writer) and `json_ser::decode_query_component`
169
- /// (Vec appender) — the wire decode loop lives in exactly one place.
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
- pub fn decode_form_component_into(
172
- src: &[u8],
173
- out: &mut [u8],
174
- ) -> std::result::Result<usize, FormDecodeError> {
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
- /// Compute the decoded length of `src` WITHOUT writing (mirror of
233
- /// [`decode_form_component_into`], minus the output). Used by the C-ABI
234
- /// "needed-size" convention: when a packed/JSON writer hits a too-small output
235
- /// buffer, the caller runs this exact-size pass ONCE (only on the rare miss)
236
- /// and reports the required size so the JS wrapper can `growExact` and retry —
237
- /// no doubling re-run loop, no 9×/8× pre-size.
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
- /// Same `Malformed` signal on a bad `%XX`, so a caller can disambiguate a
240
- /// too-small buffer from a real parse error.
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 decode_form_component_len(src: &[u8]) -> std::result::Result<usize, FormDecodeError> {
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 Ok(src.len());
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
- i += rel;
253
- let next = match src[i] {
254
- b'+' => i + 1,
255
- _ => decode_percent_at(src, i)
256
- .map(|(_, next)| next)
257
- .ok_or(FormDecodeError::Malformed)?,
258
- };
259
- written += 1;
260
- i = next;
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
- Ok(written)
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 decode_form_malformed_percent() {
357
- let mut out = [0u8; 8];
358
- assert_eq!(
359
- decode_form_component_into(b"%ZZ", &mut out),
360
- Err(FormDecodeError::Malformed)
361
- );
362
- assert_eq!(
363
- decode_form_component_into(b"%4", &mut out),
364
- Err(FormDecodeError::Malformed)
365
- );
366
- assert_eq!(
367
- decode_form_component_into(b"x%", &mut out),
368
- Err(FormDecodeError::Malformed)
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]
@@ -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 (err) {
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
- validateEmail: (input) => Number(validateEmail(input)) === 1,
186
- validateUuid: (input) => Number(validateUuid(input)) === 1,
187
- validateIpv4: (input) => Number(validateIpv4(input)) === 1,
188
- validateIpv6: (input) => Number(validateIpv6(input)) === 1,
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
- regexEscapeStr: (input) => regexEscapeStrRaw(input),
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', '')