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.
- package/Cargo.lock +25 -42
- 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 +4432 -3641
- 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 +154 -9
- package/docs/HOW_TO_ADD_AN_OP.md +10 -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/ffi/mod.rs +1 -0
- package/rust/ffi/task.rs +172 -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 +7 -2
- package/src/native/ffi/build/metrics.ts +23 -0
- 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 +40 -0
- package/src/native/ffi.ts +73 -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
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 `=`),
|
|
63
|
-
/// malformed `%XX
|
|
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
|
-
|
|
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
|
|
143
|
-
|
|
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
|
|
173
|
-
// `%FF` decodes to a
|
|
174
|
-
//
|
|
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(),
|
|
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
|
-
// ──
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
out.
|
|
390
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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
|
-
///
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
///
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
430
|
+
self.out[..RESULT_HEADER_LEN].copy_from_slice(&header);
|
|
431
|
+
self.pos
|
|
435
432
|
}
|
|
436
|
-
(size, capped)
|
|
437
433
|
}
|
|
438
434
|
|
|
439
|
-
/// Write
|
|
440
|
-
|
|
441
|
-
fn
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
475
|
-
|
|
472
|
+
w.len_prefixed(name);
|
|
473
|
+
w.len_prefixed(value);
|
|
476
474
|
count += 1;
|
|
477
475
|
}
|
|
478
|
-
|
|
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) ─────
|
package/rust/ingress/pipeline.rs
CHANGED
|
@@ -486,9 +486,9 @@ impl IngressInner {
|
|
|
486
486
|
self.limits.max_pairs,
|
|
487
487
|
) {
|
|
488
488
|
Ok(written) => written as u32,
|
|
489
|
-
|
|
490
|
-
|
|
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
|
package/rust/ingress/tests.rs
CHANGED
|
@@ -547,9 +547,10 @@ fn truncated_multibyte_query_escaped() {
|
|
|
547
547
|
parse_query: true,
|
|
548
548
|
..base_inner()
|
|
549
549
|
};
|
|
550
|
-
// `%E2%9C`
|
|
551
|
-
//
|
|
552
|
-
// and the
|
|
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":"
|
|
566
|
+
assert_eq!(query_json, br#"{"q":"%E2%9C"}"#);
|
|
566
567
|
}
|
package/rust/json/json_ser.rs
CHANGED
|
@@ -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
|
-
///
|
|
426
|
-
///
|
|
427
|
-
///
|
|
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..])
|
|
436
|
-
|
|
|
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
|
-
|
|
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
|
}
|