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
@@ -15,16 +15,14 @@ fn write_decoded_form_component(src: &[u8], out: &mut [u8], pos: &mut usize) ->
15
15
  // the remaining buffer against the ACTUAL decoded length (so a `%XX`-heavy
16
16
  // component only needs room for its decoded form, matching the pre-refactor
17
17
  // behavior where a buffer sized to the decoded length succeeds).
18
- let written = crate::util::bytes::decode_form_component_into(src, &mut out[*pos..]).map_err(
19
- |e| match e {
20
- crate::util::bytes::FormDecodeError::Malformed => {
21
- Error::from_reason("invalid %-encoding: malformed %XX sequence")
22
- }
23
- crate::util::bytes::FormDecodeError::BufferTooSmall => {
24
- Error::from_reason("packed output: buffer too small")
25
- }
26
- },
27
- )?;
18
+ //
19
+ // A malformed escape is NOT an error here: the shared decoder answers with
20
+ // the raw component, exactly like the pure-TS fallback
21
+ // (`decodeURIComponent` throws → `catch` returns the segment). The only
22
+ // error left is a too-small output buffer, which the C-ABI caller turns
23
+ // into the needed-size answer.
24
+ let written = crate::util::bytes::decode_form_component_into(src, &mut out[*pos..])
25
+ .map_err(|_| Error::from_reason("packed output: buffer too small"))?;
28
26
  *pos += written;
29
27
  let decoded_len = written as u32;
30
28
  out[len_pos..len_pos + 4].copy_from_slice(&decoded_len.to_le_bytes());
@@ -59,8 +57,9 @@ pub fn query_parse_packed_into_slice(input: &[u8], out: &mut [u8]) -> Result<usi
59
57
  /// Compute the EXACT packed output size for `input` WITHOUT writing — the
60
58
  /// "needed-size" pass for the C-ABI convention. Mirrors
61
59
  /// [`query_parse_packed_into_slice`]'s structure exactly (split on `&`, skip
62
- /// empty pairs, split at `=`), so the reported size is byte-exact and a
63
- /// malformed `%XX` still surfaces as `Err` (caller → `0`).
60
+ /// empty pairs, split at `=`) AND its decoding, including the raw fallback for a
61
+ /// malformed `%XX`: the reported size and the written size must never disagree,
62
+ /// or the caller's grow-and-retry loop would never terminate.
64
63
  #[inline]
65
64
  pub fn query_parse_packed_size(input: &[u8]) -> Result<usize> {
66
65
  let mut size = 4usize; // count prefix
@@ -72,10 +71,8 @@ pub fn query_parse_packed_size(input: &[u8]) -> Result<usize> {
72
71
  Some(eq) => (&pair[..eq], &pair[eq + 1..]),
73
72
  None => (pair, &[][..]),
74
73
  };
75
- size += 4 + crate::util::bytes::decode_form_component_len(key)
76
- .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
77
- size += 4 + crate::util::bytes::decode_form_component_len(value)
78
- .map_err(|_| Error::from_reason("invalid %-encoding: malformed %XX sequence"))?;
74
+ size += 4 + crate::util::bytes::decode_form_component_len(key);
75
+ size += 4 + crate::util::bytes::decode_form_component_len(value);
79
76
  }
80
77
  Ok(size)
81
78
  }
@@ -139,8 +136,34 @@ mod tests {
139
136
  }
140
137
 
141
138
  #[test]
142
- fn query_parse_invalid_percent_rejected() {
143
- assert!(query_parse_packed_vec(b"a=%ZZ").is_err());
139
+ fn query_parse_malformed_percent_falls_back_to_raw() {
140
+ // JS: `try { decodeURIComponent(s.replace(/\+/g," ")) } catch { return s }`
141
+ // — applied PER COMPONENT, so name and value fail independently. Checked
142
+ // against Bun's `decodeURIComponent`:
143
+ // "a=%ZZ" → [["a", "%ZZ"]]
144
+ // "a+b=%2" → [["a b", "%2"]] (`+` in the NAME still decodes)
145
+ // "x=1+2%ZZ" → [["x", "1+2%ZZ"]] (raw keeps its `+`)
146
+ // "a+b=%20" → [["a b", " "]]
147
+ let packed = query_parse_packed_vec(b"a=%ZZ").unwrap();
148
+ assert_eq!(
149
+ decode_packed_pairs(&packed),
150
+ vec![(b"a".to_vec(), b"%ZZ".to_vec())]
151
+ );
152
+ let packed = query_parse_packed_vec(b"a+b=%2").unwrap();
153
+ assert_eq!(
154
+ decode_packed_pairs(&packed),
155
+ vec![(b"a b".to_vec(), b"%2".to_vec())]
156
+ );
157
+ let packed = query_parse_packed_vec(b"x=1+2%ZZ").unwrap();
158
+ assert_eq!(
159
+ decode_packed_pairs(&packed),
160
+ vec![(b"x".to_vec(), b"1+2%ZZ".to_vec())]
161
+ );
162
+ let packed = query_parse_packed_vec(b"a+b=%20").unwrap();
163
+ assert_eq!(
164
+ decode_packed_pairs(&packed),
165
+ vec![(b"a b".to_vec(), b" ".to_vec())]
166
+ );
144
167
  }
145
168
 
146
169
  #[test]
@@ -169,12 +192,20 @@ mod tests {
169
192
  }
170
193
 
171
194
  #[test]
172
- fn query_parse_non_utf8_byte_passthrough() {
173
- // `%FF` decodes to a raw 0xFF byte; the parser does not require valid
174
- // UTF-8 in query values (callers must handle it when they do).
195
+ fn query_parse_non_utf8_escape_falls_back_to_raw() {
196
+ // `%FF` decodes to a byte that cannot appear in valid UTF-8, so JS's
197
+ // `decodeURIComponent` throws and the component is returned raw. The
198
+ // packed output must therefore hold the literal `%FF` text, NOT a 0xFF
199
+ // byte (which a JS caller could only read back lossily as U+FFFD).
175
200
  let packed = query_parse_packed_vec(b"a=%FF").unwrap();
176
201
  let pairs = decode_packed_pairs(&packed);
177
202
  assert_eq!(pairs.len(), 1);
178
- assert_eq!(pairs[0], (b"a".to_vec(), vec![0xFF]));
203
+ assert_eq!(pairs[0], (b"a".to_vec(), b"%FF".to_vec()));
204
+ // A literal invalid byte with nothing to decode still passes through.
205
+ let packed = query_parse_packed_vec(b"a=\xFF").unwrap();
206
+ assert_eq!(
207
+ decode_packed_pairs(&packed),
208
+ vec![(b"a".to_vec(), b"\xFF".to_vec())]
209
+ );
179
210
  }
180
211
  }
@@ -26,7 +26,6 @@
26
26
  // `parseQuery` + a cookie pair section iff `parseCookies`. Pair sections are
27
27
  // `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
28
28
 
29
- use std::borrow::Cow;
30
29
  use std::sync::Arc;
31
30
 
32
31
  use super::packed::{read_section, read_u32_at};
@@ -254,28 +253,30 @@ impl NativeRoute {
254
253
  }
255
254
  }
256
255
 
257
- // ── Parse + size the pair sections ──────────────────────────
258
- let mut query_size = 0usize;
259
- let mut cookie_size = 0usize;
260
- let mut query_valid = false;
261
- let mut cookie_valid = false;
256
+ // ── Assemble the result in ONE streaming pass ───────────────
257
+ // Pair sections are decoded + written as they are walked. The old
258
+ // shape sized them first and walked again, which decoded every escaped
259
+ // segment TWICE and allocated twice per segment in BOTH passes.
260
+ // The reused `scratch` holds the decoded bytes of one segment at a time.
261
+ let mut scratch: Vec<u8> = Vec::new();
262
+ let mut w = ResultWriter::new(out, RESULT_HEADER_LEN);
263
+ let mut query_capped = false;
264
+ let mut cookie_capped = false;
262
265
  if self.parse_query {
263
- let over = query.len() > self.max_query_bytes;
264
- let (size, capped) = query_section_size(query, self.max_pairs);
265
- query_size = size;
266
- query_valid = !over && !capped;
266
+ query_capped = write_query_section(&mut w, &mut scratch, query, self.max_pairs);
267
267
  }
268
268
  if self.parse_cookies {
269
- let over = cookie.len() > self.max_cookie_bytes;
270
- let (size, capped) = cookie_section_size(cookie, self.max_pairs);
271
- cookie_size = size;
272
- cookie_valid = !over && !capped;
269
+ cookie_capped = write_cookie_section(&mut w, cookie, self.max_pairs);
273
270
  }
274
271
  // validateQuery/validateCookies without a schema are no-ops (the parse
275
- // VALID bit above is the verdict); with a schema, compile would have
276
- // rejected the descriptor → the caller fell back to JS.
277
-
278
- // ── Assemble the result ─────────────────────────────────────
272
+ // VALID bit is the verdict); with a schema, compile would have rejected
273
+ // the descriptor → the caller fell back to JS. A section that is not
274
+ // parsed never reports VALID.
275
+ let query_valid = self.parse_query && !query_capped && query.len() <= self.max_query_bytes;
276
+ let cookie_valid =
277
+ self.parse_cookies && !cookie_capped && cookie.len() <= self.max_cookie_bytes;
278
+
279
+ // ── Verdict flags + header (committed LAST) ─────────────────
279
280
  let mut result_flags: u32 = 0;
280
281
  if error_code == 0 {
281
282
  result_flags |= ROUTE_RESULT_FLAG_OK;
@@ -292,25 +293,10 @@ impl NativeRoute {
292
293
  if body_valid {
293
294
  result_flags |= ROUTE_RESULT_FLAG_BODY_VALID;
294
295
  }
295
-
296
- let required = 8 + query_size + cookie_size;
297
- if out.len() < required {
298
- return Ok(required); // needed-size: report the exact size, write nothing
299
- }
300
-
301
- let mut w = 0usize;
302
- // NOTE: pass the FULL `out` slice (not `&mut out[w..]`) — `w` is the
303
- // absolute write position, so re-slicing would double-apply the offset
304
- // and scatter the header across the buffer.
305
- write_u32(out, &mut w, result_flags);
306
- write_u32(out, &mut w, error_code);
307
- if self.parse_query {
308
- write_query_section(out, &mut w, query, self.max_pairs);
309
- }
310
- if self.parse_cookies {
311
- write_cookie_section(out, &mut w, cookie, self.max_pairs);
312
- }
313
- Ok(w)
296
+ let mut header = [0u8; RESULT_HEADER_LEN];
297
+ header[..4].copy_from_slice(&result_flags.to_le_bytes());
298
+ header[4..].copy_from_slice(&error_code.to_le_bytes());
299
+ Ok(w.commit(header))
314
300
  }
315
301
  }
316
302
 
@@ -347,143 +333,148 @@ fn query_pairs(query: &[u8]) -> impl Iterator<Item = Pair<'_>> + '_ {
347
333
  /// LENIENT segment decode (matches JS `decodeSegment`): `+` → space, `%XX` →
348
334
  /// byte, result must be valid UTF-8; on ANY failure the WHOLE original segment
349
335
  /// is returned unchanged (with `+` AND `%` intact — the JS catch returns `s`).
350
- fn decode_segment_lenient(seg: &[u8]) -> Cow<'_, [u8]> {
351
- if memchr::memchr2(b'+', b'%', seg).is_none() {
352
- return Cow::Borrowed(seg);
353
- }
354
- let mut replaced = Vec::with_capacity(seg.len());
355
- for &b in seg {
356
- replaced.push(if b == b'+' { b' ' } else { b });
357
- }
358
- match decode_percent_utf8(&replaced) {
359
- Some(decoded) => Cow::Owned(decoded),
360
- None => Cow::Borrowed(seg),
361
- }
336
+ ///
337
+ /// Decodes into a caller-provided `scratch` (reused across every segment and
338
+ /// pair of the call) so the hot path NEVER allocates per segment — the old
339
+ /// implementation built `Vec::with_capacity` for the `+` replacement AND
340
+ /// another for the percent decode, i.e. two allocations per escaped segment,
341
+ /// twice per call (size pass + write pass).
342
+ ///
343
+ /// The slice returned borrows either `seg` (fast path / lenient fallback) or
344
+ /// `scratch`; it is valid only until the next call with the same scratch.
345
+ #[inline]
346
+ fn decode_segment_scratch<'a>(seg: &'a [u8], scratch: &'a mut Vec<u8>) -> &'a [u8] {
347
+ // Semantics live in ONE place (`util::bytes`) so the route stack and the
348
+ // packed pair parsers can never disagree: `+` → space, `%XX` → byte, and a
349
+ // malformed escape or an invalid-UTF-8 result falls back to the WHOLE
350
+ // original segment (JS `try { decodeURIComponent(...) } catch { return s }`).
351
+ crate::util::bytes::decode_form_component_scratch(seg, scratch)
362
352
  }
363
353
 
364
- /// The decoded byte length of `seg` under the lenient rules (the original
365
- /// length when decoding fails — the segment is passed through raw).
366
- #[inline]
367
- fn decode_segment_len(seg: &[u8]) -> usize {
368
- decode_segment_lenient(seg).len()
354
+ // ── Streaming result writer (ONE pass, zero per-segment alloc) ─────
355
+
356
+ /// Bytes reserved at the head of the result for the `[flags u32][error u32]`
357
+ /// verdict header.
358
+ const RESULT_HEADER_LEN: usize = 8;
359
+
360
+ /// Streaming result writer: appends bytes while tracking the EXACT required
361
+ /// size, so a single pass produces both the packed result AND the needed-size
362
+ /// answer. The previous shape walked every pair section TWICE (a sizing pass
363
+ /// and a write pass), decoding every escaped segment in both.
364
+ ///
365
+ /// Once the caller's buffer proves too small the writer stops copying (and
366
+ /// stops patching) but KEEPS counting `needed`, so the needed-size convention
367
+ /// still reports the exact size from the same pass. The verdict header is
368
+ /// committed LAST, so a too-small buffer is left untouched — the contract
369
+ /// `run()`'s callers rely on.
370
+ struct ResultWriter<'a> {
371
+ out: &'a mut [u8],
372
+ pos: usize,
373
+ needed: usize,
374
+ full: bool,
369
375
  }
370
376
 
371
- /// Percent-decode `input` (which has already had `+`→space applied) and
372
- /// validate the result is well-formed UTF-8. Returns `None` on a malformed
373
- /// `%XX` OR invalid UTF-8 (both throw in `decodeURIComponent`).
374
- fn decode_percent_utf8(input: &[u8]) -> Option<Vec<u8>> {
375
- let mut out = Vec::with_capacity(input.len());
376
- let mut i = 0usize;
377
- while i < input.len() {
378
- match input[i] {
379
- b'%' => {
380
- if i + 2 >= input.len() {
381
- return None;
382
- }
383
- let hi = hex_val(input[i + 1])?;
384
- let lo = hex_val(input[i + 2])?;
385
- out.push((hi << 4) | lo);
386
- i += 3;
387
- }
388
- b => {
389
- out.push(b);
390
- i += 1;
377
+ impl<'a> ResultWriter<'a> {
378
+ #[inline]
379
+ fn new(out: &'a mut [u8], header_len: usize) -> Self {
380
+ Self {
381
+ full: out.len() < header_len,
382
+ out,
383
+ pos: header_len,
384
+ needed: header_len,
385
+ }
386
+ }
387
+
388
+ /// Append `bytes`, tracking the required size even after the buffer is full.
389
+ #[inline]
390
+ fn bytes(&mut self, bytes: &[u8]) {
391
+ self.needed += bytes.len();
392
+ if !self.full {
393
+ let end = self.pos + bytes.len();
394
+ if end <= self.out.len() {
395
+ self.out[self.pos..end].copy_from_slice(bytes);
396
+ self.pos = end;
397
+ } else {
398
+ self.full = true;
391
399
  }
392
400
  }
393
401
  }
394
- std::str::from_utf8(&out).ok()?;
395
- Some(out)
396
- }
397
402
 
398
- /// Hex digit → nibble (None for non-hex).
399
- #[inline]
400
- fn hex_val(b: u8) -> Option<u8> {
401
- match b {
402
- b'0'..=b'9' => Some(b - b'0'),
403
- b'a'..=b'f' => Some(b - b'a' + 10),
404
- b'A'..=b'F' => Some(b - b'A' + 10),
405
- _ => None,
403
+ #[inline]
404
+ fn u32(&mut self, value: u32) {
405
+ self.bytes(&value.to_le_bytes());
406
406
  }
407
- }
408
407
 
409
- // ── Pair-section size + write (two passes, zero alloc) ────────────
408
+ /// Append `[u32 len][bytes]` — the pair-section element layout.
409
+ #[inline]
410
+ fn len_prefixed(&mut self, bytes: &[u8]) {
411
+ self.u32(bytes.len() as u32);
412
+ self.bytes(bytes);
413
+ }
410
414
 
411
- /// Size pass over decoded query pairs (name/value both lenient-decoded).
412
- fn query_section_size(query: &[u8], max_pairs: usize) -> (usize, bool) {
413
- let mut size = 4usize;
414
- let mut capped = false;
415
- for (count, pair) in query_pairs(query).enumerate() {
416
- if max_pairs > 0 && count >= max_pairs {
417
- capped = true;
418
- break;
415
+ /// Patch a previously reserved u32 (the section pair count) in place.
416
+ #[inline]
417
+ fn patch_u32(&mut self, at: usize, value: u32) {
418
+ if !self.full && at + 4 <= self.out.len() {
419
+ self.out[at..at + 4].copy_from_slice(&value.to_le_bytes());
419
420
  }
420
- size += 4 + decode_segment_len(pair.name) + 4 + decode_segment_len(pair.value);
421
421
  }
422
- (size, capped)
423
- }
424
422
 
425
- /// Size pass over raw cookie pairs (no URL-decoding).
426
- fn cookie_section_size(cookie: &[u8], max_pairs: usize) -> (usize, bool) {
427
- let mut size = 4usize;
428
- let mut capped = false;
429
- for (count, (name, value)) in cookie_pairs(cookie).enumerate() {
430
- if max_pairs > 0 && count >= max_pairs {
431
- capped = true;
432
- break;
423
+ /// Commit the verdict header and return the bytes written — or, when the
424
+ /// buffer was too small, the EXACT required size (nothing committed).
425
+ #[inline]
426
+ fn commit(self, header: [u8; RESULT_HEADER_LEN]) -> usize {
427
+ if self.full || self.needed > self.out.len() {
428
+ return self.needed;
433
429
  }
434
- size += 4 + name.len() + 4 + value.len();
430
+ self.out[..RESULT_HEADER_LEN].copy_from_slice(&header);
431
+ self.pos
435
432
  }
436
- (size, capped)
437
433
  }
438
434
 
439
- /// Write a decoded segment as `[u32 len][bytes]` into `out` at `pos`.
440
- #[inline]
441
- fn write_segment(out: &mut [u8], pos: &mut usize, seg: &[u8]) {
442
- let len = seg.len();
443
- out[*pos..*pos + 4].copy_from_slice(&(len as u32).to_le_bytes());
444
- *pos += 4;
445
- out[*pos..*pos + len].copy_from_slice(seg);
446
- *pos += len;
447
- }
448
-
449
- /// Write the query pair section (lenient-decoded names/values).
450
- fn write_query_section(out: &mut [u8], pos: &mut usize, query: &[u8], max_pairs: usize) {
451
- let count_pos = *pos;
452
- write_u32(out, pos, 0); // count placeholder
435
+ /// Write the query pair section (lenient-decoded names/values) in ONE pass.
436
+ /// Returns `true` when `max_pairs` capped the section (→ not valid).
437
+ fn write_query_section(
438
+ w: &mut ResultWriter<'_>,
439
+ scratch: &mut Vec<u8>,
440
+ query: &[u8],
441
+ max_pairs: usize,
442
+ ) -> bool {
443
+ let count_pos = w.pos;
444
+ w.u32(0); // count placeholder, patched below
453
445
  let mut count = 0usize;
446
+ let mut capped = false;
454
447
  for pair in query_pairs(query) {
455
448
  if max_pairs > 0 && count >= max_pairs {
449
+ capped = true;
456
450
  break;
457
451
  }
458
- write_segment(out, pos, &decode_segment_lenient(pair.name));
459
- write_segment(out, pos, &decode_segment_lenient(pair.value));
452
+ w.len_prefixed(decode_segment_scratch(pair.name, scratch));
453
+ w.len_prefixed(decode_segment_scratch(pair.value, scratch));
460
454
  count += 1;
461
455
  }
462
- out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
456
+ w.patch_u32(count_pos, count as u32);
457
+ capped
463
458
  }
464
459
 
465
- /// Write the cookie pair section (raw trimmed/unquoted names/values).
466
- fn write_cookie_section(out: &mut [u8], pos: &mut usize, cookie: &[u8], max_pairs: usize) {
467
- let count_pos = *pos;
468
- write_u32(out, pos, 0); // count placeholder
460
+ /// Write the cookie pair section (raw trimmed/unquoted names/values) in ONE pass.
461
+ /// Returns `true` when `max_pairs` capped the section (→ not valid).
462
+ fn write_cookie_section(w: &mut ResultWriter<'_>, cookie: &[u8], max_pairs: usize) -> bool {
463
+ let count_pos = w.pos;
464
+ w.u32(0); // count placeholder, patched below
469
465
  let mut count = 0usize;
466
+ let mut capped = false;
470
467
  for (name, value) in cookie_pairs(cookie) {
471
468
  if max_pairs > 0 && count >= max_pairs {
469
+ capped = true;
472
470
  break;
473
471
  }
474
- write_segment(out, pos, name);
475
- write_segment(out, pos, value);
472
+ w.len_prefixed(name);
473
+ w.len_prefixed(value);
476
474
  count += 1;
477
475
  }
478
- out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
479
- }
480
-
481
- /// Write a u32 LE into `out` at `pos`, advancing `pos`. Caller guarantees
482
- /// capacity (sizes are pre-computed).
483
- #[inline]
484
- fn write_u32(out: &mut [u8], pos: &mut usize, value: u32) {
485
- out[*pos..*pos + 4].copy_from_slice(&value.to_le_bytes());
486
- *pos += 4;
476
+ w.patch_u32(count_pos, count as u32);
477
+ capped
487
478
  }
488
479
 
489
480
  // ── napi boundary: the `Route` class (Node/fallback transport) ─────
@@ -486,9 +486,9 @@ impl IngressInner {
486
486
  self.limits.max_pairs,
487
487
  ) {
488
488
  Ok(written) => written as u32,
489
- Err(crate::json::json_ser::QueryJsonError::Malformed) => {
490
- return Err(Error::from_reason("query parse failed"))
491
- }
489
+ // A malformed `%XX` is NOT an error: the decoder returns the
490
+ // raw component, exactly like the JS `decodeURIComponent`
491
+ // fallback (this used to 400 where JS answers 200).
492
492
  Err(crate::json::json_ser::QueryJsonError::BufferTooSmall) => {
493
493
  truncated = true;
494
494
  0
@@ -547,9 +547,10 @@ fn truncated_multibyte_query_escaped() {
547
547
  parse_query: true,
548
548
  ..base_inner()
549
549
  };
550
- // `%E2%9C` decodes to 2 bytes of a 3-byte sequence → invalid UTF-8. The
551
- // JSON writer must escape every byte as `\u00XX` (not emit invalid UTF-8),
552
- // and the length accounting must match (6 bytes per byte).
550
+ // `%E2%9C` is a truncated 3-byte sequence: `decodeURIComponent` throws, so
551
+ // the component is returned RAW ("%E2%9C") — the same thing the JS
552
+ // fallback does — and the request is NOT rejected. The JSON writer then
553
+ // sees plain ASCII and the length accounting is the raw length.
553
554
  let input = packed_input(0, "/api?q=%E2%9C".as_bytes(), b"127.0.0.1", b"rid");
554
555
  let mut out = vec![0u8; 4096];
555
556
  inner.handle_packed(&input, b"", &mut out).unwrap();
@@ -562,5 +563,5 @@ fn truncated_multibyte_query_escaped() {
562
563
  out[OUT_QUERY_JSON_LEN + 3],
563
564
  ]) as usize;
564
565
  let query_json = &out[OUT_DATA_START..OUT_DATA_START + query_len];
565
- assert_eq!(query_json, br#"{"q":"\u00e2\u009c"}"#);
566
+ assert_eq!(query_json, br#"{"q":"%E2%9C"}"#);
566
567
  }
@@ -416,15 +416,18 @@ pub fn packed_pairs_to_json_into_slice(
416
416
  /// same split the old packed pipeline had between its parse and write steps).
417
417
  #[derive(Debug, PartialEq, Eq)]
418
418
  pub enum QueryJsonError {
419
- Malformed,
420
419
  BufferTooSmall,
421
420
  }
422
421
 
423
422
  /// Decode a single URL-form component (`+` → space, `%XX` → byte) into `out`.
424
423
  ///
425
- /// Shared decoder with `query_parser::write_decoded_form_component` (this one
426
- /// appends to a `Vec` rather than writing a length-prefixed slice); errors on
427
- /// malformed `%XX`.
424
+ /// Uses the LENIENT arm of the shared decoder — the same one the packed pair
425
+ /// parsers use — so a malformed escape or an invalid-UTF-8 escape yields the RAW
426
+ /// component exactly like the pure-TS fallback. (This used to be a strict arm
427
+ /// that rejected malformed `%XX`, which made the native pipeline answer 400
428
+ /// where the JS path returns the raw value.) Bytes that are invalid UTF-8 in the
429
+ /// INPUT still reach the JSON writer verbatim; the escaping below keeps the
430
+ /// output valid JSON.
428
431
  #[inline]
429
432
  fn decode_query_component(
430
433
  src: &[u8],
@@ -432,12 +435,8 @@ fn decode_query_component(
432
435
  ) -> std::result::Result<(), QueryJsonError> {
433
436
  let start = out.len();
434
437
  out.resize(start + src.len(), 0);
435
- let written = crate::util::bytes::decode_form_component_into(src, &mut out[start..]).map_err(
436
- |e| match e {
437
- crate::util::bytes::FormDecodeError::Malformed => QueryJsonError::Malformed,
438
- crate::util::bytes::FormDecodeError::BufferTooSmall => QueryJsonError::BufferTooSmall,
439
- },
440
- )?;
438
+ let written = crate::util::bytes::decode_form_component_into(src, &mut out[start..])
439
+ .map_err(|_| QueryJsonError::BufferTooSmall)?;
441
440
  out.truncate(start + written);
442
441
  Ok(())
443
442
  }
@@ -864,7 +863,10 @@ mod tests {
864
863
  "query={raw:?}"
865
864
  );
866
865
  }
867
- (Err(_), Err(QueryJsonError::Malformed)) => {} // both reject malformed %XX
866
+ // Both paths share the lenient decoder now, so a malformed
867
+ // `%XX` produces the SAME raw component on both sides (the
868
+ // `Err` arm that used to allow "both reject" is gone with the
869
+ // strict contract).
868
870
  (other, _) => {
869
871
  panic!("mismatched outcome for query={raw:?}: {other:?} vs {direct:?}")
870
872
  }
package/rust/lib.rs CHANGED
@@ -59,6 +59,7 @@ pub mod json;
59
59
  pub mod metrics;
60
60
  pub mod payload;
61
61
  pub mod selection;
62
+ pub mod task;
62
63
  pub mod util;
63
64
 
64
65
  // ── Unit tests (cargo test) ───────────────────────────────────────