castrum 0.9.4 → 0.9.6

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 (58) hide show
  1. package/Cargo.lock +25 -42
  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 +4432 -3641
  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 +154 -9
  19. package/docs/HOW_TO_ADD_AN_OP.md +10 -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/ffi/mod.rs +1 -0
  27. package/rust/ffi/task.rs +172 -0
  28. package/rust/http/form.rs +6 -2
  29. package/rust/http/query_parser.rs +53 -22
  30. package/rust/ingress/native_route.rs +131 -140
  31. package/rust/ingress/pipeline.rs +3 -3
  32. package/rust/ingress/tests.rs +5 -4
  33. package/rust/json/json_ser.rs +13 -11
  34. package/rust/lib.rs +1 -0
  35. package/rust/task/completion.rs +119 -0
  36. package/rust/task/mod.rs +65 -0
  37. package/rust/task/ops.rs +503 -0
  38. package/rust/task/runtime.rs +220 -0
  39. package/rust/task/tests.rs +231 -0
  40. package/rust/util/bytes.rs +238 -51
  41. package/src/ingress/health.ts +1 -2
  42. package/src/native/ffi/build/codecs.ts +29 -7
  43. package/src/native/ffi/build/compress.ts +8 -0
  44. package/src/native/ffi/build/instances.ts +7 -2
  45. package/src/native/ffi/build/metrics.ts +23 -0
  46. package/src/native/ffi/build/task.ts +79 -0
  47. package/src/native/ffi/build/util.ts +18 -0
  48. package/src/native/ffi/build.ts +2 -0
  49. package/src/native/ffi/selftest.ts +3 -1
  50. package/src/native/ffi/types.ts +40 -0
  51. package/src/native/ffi.ts +73 -14
  52. package/src/native/loader.ts +53 -3
  53. package/src/rust-ffi/scalar/http.ts +54 -16
  54. package/src/rust-ffi/text.ts +25 -3
  55. package/src/shared/bytes.ts +19 -0
  56. package/src/task/index.ts +24 -0
  57. package/src/task/op.ts +171 -0
  58. 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
@@ -144,10 +144,15 @@ export function buildInstances(
144
144
  return Number(schemaValidatorValidateRaw(inner, doc, lenOrView(doc))) === 1
145
145
  },
146
146
  queryValidate(inner, qs) {
147
- // `qs` is a cstring ARG — engine-transcoded (zero JS encode).
147
+ // `qs` is a cstring ARG — engine-transcoded (zero JS encode). A raw NUL is
148
+ // not a valid query byte, and it would TRUNCATE the arg so only a prefix
149
+ // got validated: a gate that says "this query passed" must not be foolable.
150
+ if (hasNul(qs)) return false
148
151
  return Number(queryValidateSym(inner, qs)) === 1
149
152
  },
150
153
  cookieValidate(inner, header) {
154
+ // Same contract as `queryValidate`: a NUL would validate only a prefix.
155
+ if (hasNul(header)) return false
151
156
  return Number(cookieValidateSym(inner, header)) === 1
152
157
  },
153
158
  sessionSeal(id, dataJson, expSecs, secret) {
@@ -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) {
@@ -0,0 +1,79 @@
1
+ // src/native/ffi/build/task.ts — off-thread task runtime BunFFI methods.
2
+ //
3
+ // The thinnest possible binding: `rust/task/` owns the pool, the result ring,
4
+ // and the batched doorbell. This file only adapts the `(ptr,len)` ABI pair
5
+ // (probe-gated `buffer`/`buffer_length` vs explicit lengths) and converts the
6
+ // `u64_fast` byte counts to numbers.
7
+ //
8
+ // See docs/RND-CONCURRENCY.md for the design and the measured costs.
9
+
10
+ import type { BunFFI, Raw0, Raw2, Raw4, Raw6 } from '../types'
11
+ import type { BuildCtx } from './util'
12
+
13
+ /** Raw 1-arg C-ABI symbol signature (a lone scalar arg or a bare pointer). */
14
+ type Raw1 = (a: unknown) => number | bigint
15
+
16
+ /**
17
+ * Build the off-thread task-runtime methods of the BunFFI surface. `ctx`
18
+ * supplies the probe-gated length adapter (`lenOrView`).
19
+ */
20
+ export function buildTask(
21
+ sym: Record<string, (...a: unknown[]) => unknown>,
22
+ ctx: BuildCtx,
23
+ ): Partial<BunFFI> {
24
+ const { lenOrView } = ctx
25
+
26
+ const init = sym.castrum_task_init as Raw1
27
+ const submit = sym.castrum_task_submit as Raw4
28
+ const submitOut = sym.castrum_task_submit_out as Raw6
29
+ const submitSlice = sym.castrum_task_submit_slice as Raw6
30
+ const drain = sym.castrum_task_drain as Raw2
31
+ const pending = sym.castrum_task_pending as Raw0
32
+ const cancel = sym.castrum_task_cancel as Raw1
33
+ const setDoorbell = sym.castrum_task_set_doorbell as Raw1
34
+ const shutdown = sym.castrum_task_shutdown as Raw0
35
+ const threads = sym.castrum_task_threads as Raw0
36
+
37
+ return {
38
+ taskInit: (n) => Number(init(n)),
39
+ // `args` is copied onto the worker by Rust, so the caller's pooled buffer
40
+ // may be released as soon as this returns.
41
+ taskSubmit: (op, args, taskId) => Number(submit(op, args, lenOrView(args), taskId)),
42
+ taskSubmitOut: (op, args, taskId, output) =>
43
+ Number(submitOut(op, args, lenOrView(args), taskId, output, lenOrView(output))),
44
+ // Zero-copy INPUT: the header is copied by Rust, the payload is read in
45
+ // place — which is why the runtime must hold a reference to `data` for the
46
+ // whole task (see `Deferred.keep` in src/task/runtime.ts).
47
+ taskSubmitSlice: (op, hdr, data, taskId) =>
48
+ Number(submitSlice(op, hdr, lenOrView(hdr), data, lenOrView(data), taskId)),
49
+ taskDrain: (output) => Number(drain(output, lenOrView(output))),
50
+ taskPending: () => Number(pending()),
51
+ taskCancel: (taskId) => Number(cancel(taskId)),
52
+ taskSetDoorbell: (ptr) => Number(setDoorbell(ptr)),
53
+ taskShutdown: () => Number(shutdown()),
54
+ taskThreads: () => Number(threads()),
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Bind-time self-test for the task symbols. The pool is intentionally NOT
60
+ * started here (no threads at import time) and no completion is produced;
61
+ * the full submit→drain→doorbell path is exercised by `test/unit/task/`.
62
+ */
63
+ export function selfTestTask(b: BunFFI): boolean {
64
+ try {
65
+ if (typeof b.taskPending() !== 'number') return false
66
+ if (typeof b.taskThreads() !== 'number' || b.taskThreads() < 0) return false
67
+ // `0` disables the doorbell — safe at bind time (nothing is in flight).
68
+ if (b.taskSetDoorbell(0) !== 1) return false
69
+ // An empty ring → 0; exercises the drain symbol without allocating anything.
70
+ if (typeof b.taskDrain(new Uint8Array(4)) !== 'number') return false
71
+ // NOT called: submitting here would start the pool threads at bind time.
72
+ // The full submit/submit_out path is exercised by test/unit/task/.
73
+ if (typeof b.taskSubmitOut !== 'function') return false
74
+ if (typeof b.taskSubmitSlice !== 'function') return false
75
+ return true
76
+ } catch {
77
+ return false
78
+ }
79
+ }
@@ -42,6 +42,24 @@ export interface BuildCtx {
42
42
  /** Coerce an optional boolean to the C `u8` flag (1/0). */
43
43
  export const flag = (v?: boolean): number => (v ? 1 : 0)
44
44
 
45
+ /**
46
+ * Does this string contain a NUL (`U+0000`)?
47
+ *
48
+ * `bun:ffi` transcodes a JS string passed to a `cstring` ARG into a
49
+ * NUL-TERMINATED UTF-8 buffer, so an embedded NUL SILENTLY TRUNCATES the value
50
+ * on the native side (Bun's docs describe the transcode; the truncation is the
51
+ * C convention it inherits). That is fine when the string is developer-supplied
52
+ * config or NUL-free by construction — and it is a real bug when the callee
53
+ * answers with a VERDICT (validators, query/cookie gates, batch validators: a
54
+ * truncated prefix passes where the full input fails) or when the exact bytes
55
+ * are the result (`regexEscape`, metric label values).
56
+ *
57
+ * Guard contract for every `cstring` ARG reachable from user input: either
58
+ * reject/short-circuit on NUL, or route to the `(ptr, usize)` byte form which
59
+ * carries the exact length. `test/unit/native/cstring-nul.test.ts` pins this.
60
+ */
61
+ export { hasNul } from '../../../shared/bytes'
62
+
45
63
  /**
46
64
  * Output-buffer allocation for the FFI wrappers. The native write fills
47
65
  * `[0, w)` and only `subarray(0, w)` escapes, so zero-initialization is pure