castrum 0.9.2 → 0.9.4

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 (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,881 @@
1
+ // rust/json/json_ser.rs — JSON serialization helpers extracted from ingress.rs
2
+ // Zero-alloc JSON escaping, cookie→JSON and query→JSON conversion
3
+ // Uses memchr-based skip for bulk unescaped bytes (~95% path)
4
+
5
+ use crate::util::bytes::{cookie_pairs, HEX_LOWER as JSON_HEX_LOWER};
6
+
7
+ /// True when every byte is ASCII (< 0x80), checked word-at-a-time.
8
+ ///
9
+ /// Equivalent to `bytes.iter().all(|&b| b < 0x80)` but skips the per-byte
10
+ /// loop for the bulk of the buffer (one u64 high-bit test per 8 bytes).
11
+ /// `as_chunks` types each chunk as `[u8; 8]`, so the word conversion cannot
12
+ /// panic and the bounds check elides on the hot path.
13
+ #[inline(always)]
14
+ fn is_ascii(bytes: &[u8]) -> bool {
15
+ let (chunks, remainder) = bytes.as_chunks::<8>();
16
+ for word in chunks {
17
+ let w = u64::from_le_bytes(*word);
18
+ if (w & 0x8080_8080_8080_8080) != 0 {
19
+ return false;
20
+ }
21
+ }
22
+ remainder.iter().all(|&b| b < 0x80)
23
+ }
24
+
25
+ /// Determine whether bytes are valid UTF-8 (cached via a bit check).
26
+ #[inline(always)]
27
+ fn is_valid_utf8(bytes: &[u8]) -> bool {
28
+ // Fast path: small ASCII-only strings are very common.
29
+ if bytes.is_empty() {
30
+ return true;
31
+ }
32
+ // For pure ASCII we can skip the full simdutf8 check.
33
+ if is_ascii(bytes) {
34
+ return true;
35
+ }
36
+ std::str::from_utf8(bytes).is_ok()
37
+ }
38
+
39
+ /// Compute the JSON-escaped length without writing, validating UTF-8 in the
40
+ /// SAME pass as the memchr3 escape scan (previously `is_valid_utf8` + the
41
+ /// memchr3 scan were two full passes). Uses memchr to find special characters
42
+ /// in bulk.
43
+ ///
44
+ /// # Correctness (matches `write_json_escaped`)
45
+ ///
46
+ /// memchr3's needles (`"` 0x22, `\` 0x5C, `\n` 0x0A) are all < 0x80, and a valid
47
+ /// UTF-8 multi-byte sequence is made only of bytes >= 0x80, so an ASCII byte
48
+ /// can never appear inside a multi-byte sequence. Splitting the buffer at
49
+ /// memchr3 hits therefore never splits a multi-byte sequence, and
50
+ /// `str::from_utf8(whole)` is OK ⇔ every gap is OK. `write_json_escaped`
51
+ /// derives the same valid/invalid decision from `is_valid_utf8`, so the length
52
+ /// estimate stays an EXACT accounting of the written output (enforced by the
53
+ /// `write_json_escaped_never_overflows_exact_buffer` test).
54
+ #[inline(always)]
55
+ pub fn json_escaped_len(bytes: &[u8]) -> usize {
56
+ // Pure ASCII is valid UTF-8 by construction — skip gap validation and just
57
+ // do the escape accounting.
58
+ if is_ascii(bytes) {
59
+ return json_escaped_len_impl(bytes);
60
+ }
61
+
62
+ let mut total = bytes.len(); // start with all bytes as 1
63
+ let mut offset = 0usize;
64
+
65
+ while let Some(pos) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
66
+ let abs = offset + pos;
67
+ if std::str::from_utf8(&bytes[offset..abs]).is_err() {
68
+ // Invalid UTF-8 → the binary escape path escapes EVERY byte as
69
+ // `\u00XX` (6 bytes each), matching `write_json_escaped`.
70
+ return bytes.len().saturating_mul(6);
71
+ }
72
+ // Control chars before the special are escaped too — they contribute
73
+ // the same extra count the writer emits. This keeps the length an
74
+ // EXACT accounting of the escaped output, so the output is always
75
+ // RFC-8259-valid and never depends on memchr3's needle set.
76
+ total += control_escape_extra(&bytes[offset..abs]);
77
+
78
+ // memchr3 only matches ", \, \n — every hit is a 2-byte escape (+1).
79
+ total += 1;
80
+
81
+ offset = abs + 1;
82
+ }
83
+
84
+ // Trailing run after the last special — may contain control chars.
85
+ if std::str::from_utf8(&bytes[offset..]).is_err() {
86
+ return bytes.len().saturating_mul(6);
87
+ }
88
+ total += control_escape_extra(&bytes[offset..]);
89
+ total
90
+ }
91
+
92
+ /// Extra bytes (beyond the base 1 already counted per input byte) required to
93
+ /// escape control characters in a run that contains NO `"`, `\` or `\n`
94
+ /// (those three memchr3 specials are accounted for by the caller).
95
+ ///
96
+ /// - `\r` (0x0d), `\t` (0x09), 0x08 and 0x0c become 2-byte escapes → +1.
97
+ /// - every other control char (< 0x20) becomes `\uXXXX` (6 bytes → +5).
98
+ #[inline(always)]
99
+ fn control_escape_extra(run: &[u8]) -> usize {
100
+ let mut extra = 0;
101
+ for &b in run {
102
+ if b < 0x20 {
103
+ extra += if b == b'\r' || b == b'\t' || b == 0x08 || b == 0x0c {
104
+ 1
105
+ } else {
106
+ 5
107
+ };
108
+ }
109
+ }
110
+ extra
111
+ }
112
+
113
+ /// Inner: assumes valid UTF-8, uses memchr for skip.
114
+ #[inline(always)]
115
+ fn json_escaped_len_impl(bytes: &[u8]) -> usize {
116
+ let mut total = bytes.len(); // start with all bytes as 1
117
+ let mut offset = 0;
118
+
119
+ // Use memchr3 to find any of the special chars: ", \, \n
120
+ while let Some(pos) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
121
+ let abs = offset + pos;
122
+
123
+ // Control chars before the special are escaped too — they contribute
124
+ // the same extra count the writer emits. This keeps the length an
125
+ // EXACT accounting of the escaped output, so the output is always
126
+ // RFC-8259-valid and never depends on memchr3's needle set.
127
+ total += control_escape_extra(&bytes[offset..abs]);
128
+
129
+ // memchr3 only matches ", \, \n — every hit is a 2-byte escape (+1).
130
+ total += 1;
131
+
132
+ offset = abs + 1;
133
+ }
134
+
135
+ // Trailing run after the last special — may contain control chars.
136
+ total += control_escape_extra(&bytes[offset..]);
137
+ total
138
+ }
139
+
140
+ /// Write JSON-escaped bytes into the buffer at the current position.
141
+ /// Returns the number of bytes written.
142
+ #[inline(always)]
143
+ pub fn write_json_escaped(out: &mut [u8], pos: &mut usize, bytes: &[u8]) -> usize {
144
+ let start = *pos;
145
+ if is_valid_utf8(bytes) {
146
+ write_json_escaped_utf8(out, pos, bytes);
147
+ } else {
148
+ // Binary: escape every byte as \u00XX
149
+ for &b in bytes {
150
+ out[*pos] = b'\\';
151
+ out[*pos + 1] = b'u';
152
+ out[*pos + 2] = b'0';
153
+ out[*pos + 3] = b'0';
154
+ out[*pos + 4] = JSON_HEX_LOWER[(b >> 4) as usize];
155
+ out[*pos + 5] = JSON_HEX_LOWER[(b & 0x0f) as usize];
156
+ *pos += 6;
157
+ }
158
+ }
159
+ *pos - start
160
+ }
161
+
162
+ /// Write a run of bytes that contains no `"`, `\` or `\n`, escaping any
163
+ /// control characters (< 0x20) present. Assumes `out` has room for
164
+ /// `run.len() + control_escape_extra(run)` bytes.
165
+ #[inline(always)]
166
+ fn write_escaped_run(out: &mut [u8], pos: &mut usize, run: &[u8]) {
167
+ if run.is_empty() {
168
+ return;
169
+ }
170
+ let mut i = 0;
171
+ while i < run.len() {
172
+ let b = run[i];
173
+ match b {
174
+ b'\r' => {
175
+ out[*pos] = b'\\';
176
+ out[*pos + 1] = b'r';
177
+ *pos += 2;
178
+ i += 1;
179
+ }
180
+ b'\t' => {
181
+ out[*pos] = b'\\';
182
+ out[*pos + 1] = b't';
183
+ *pos += 2;
184
+ i += 1;
185
+ }
186
+ 0x08 => {
187
+ out[*pos] = b'\\';
188
+ out[*pos + 1] = b'b';
189
+ *pos += 2;
190
+ i += 1;
191
+ }
192
+ 0x0c => {
193
+ out[*pos] = b'\\';
194
+ out[*pos + 1] = b'f';
195
+ *pos += 2;
196
+ i += 1;
197
+ }
198
+ b if b < 0x20 => {
199
+ // Control character (< 0x20) → \u00XX
200
+ out[*pos] = b'\\';
201
+ out[*pos + 1] = b'u';
202
+ out[*pos + 2] = b'0';
203
+ out[*pos + 3] = b'0';
204
+ out[*pos + 4] = JSON_HEX_LOWER[(b >> 4) as usize];
205
+ out[*pos + 5] = JSON_HEX_LOWER[(b & 0x0f) as usize];
206
+ *pos += 6;
207
+ i += 1;
208
+ }
209
+ _ => {
210
+ // Bulk copy of safe bytes up to next control char.
211
+ let rest = &run[i..];
212
+ match rest.iter().position(|&c| c < 0x20) {
213
+ Some(n) => {
214
+ out[*pos..*pos + n].copy_from_slice(&rest[..n]);
215
+ *pos += n;
216
+ i += n;
217
+ }
218
+ None => {
219
+ out[*pos..*pos + rest.len()].copy_from_slice(rest);
220
+ *pos += rest.len();
221
+ i = run.len();
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ /// Write escaped bytes for valid UTF-8 input.
230
+ #[inline(always)]
231
+ fn write_json_escaped_utf8(out: &mut [u8], pos: &mut usize, bytes: &[u8]) {
232
+ let mut offset = 0usize;
233
+
234
+ while let Some(mut idx) = memchr::memchr3(b'"', b'\\', b'\n', &bytes[offset..]) {
235
+ idx += offset;
236
+
237
+ // Copy the unescaped bytes before the special character, escaping any
238
+ // control chars memchr3 skipped — a raw control char (e.g. `\t` before
239
+ // a `"`) would produce RFC-8259-invalid JSON.
240
+ write_escaped_run(out, pos, &bytes[offset..idx]);
241
+
242
+ let b = bytes[idx];
243
+ if b == b'"' {
244
+ out[*pos] = b'\\';
245
+ out[*pos + 1] = b'"';
246
+ *pos += 2;
247
+ } else if b == b'\\' {
248
+ out[*pos] = b'\\';
249
+ out[*pos + 1] = b'\\';
250
+ *pos += 2;
251
+ } else {
252
+ // memchr3 only matches '"', '\\' and '\n'.
253
+ debug_assert_eq!(b, b'\n');
254
+ out[*pos] = b'\\';
255
+ out[*pos + 1] = b'n';
256
+ *pos += 2;
257
+ }
258
+
259
+ offset = idx + 1;
260
+ }
261
+
262
+ // Trailing run after the last special — may contain control chars.
263
+ write_escaped_run(out, pos, &bytes[offset..]);
264
+ }
265
+
266
+ /// Parse cookies from `input` and write JSON to `out` with max_pairs limit.
267
+ #[inline]
268
+ pub fn cookie_json_into_slice(
269
+ input: &[u8],
270
+ out: &mut [u8],
271
+ max_pairs: usize,
272
+ ) -> std::result::Result<usize, String> {
273
+ if out.len() < 2 {
274
+ return Err("output buffer too small for cookie JSON".to_string());
275
+ }
276
+
277
+ out[0] = b'{';
278
+ let mut pos = 1usize;
279
+
280
+ for (count, (name, value)) in cookie_pairs(input).take(max_pairs).enumerate() {
281
+ let needed =
282
+ (if count == 0 { 0 } else { 1 }) + 5 + json_escaped_len(name) + json_escaped_len(value);
283
+
284
+ if needed > out.len().saturating_sub(pos) {
285
+ return Err("output buffer too small for cookie JSON".to_string());
286
+ }
287
+
288
+ if count != 0 {
289
+ out[pos] = b',';
290
+ pos += 1;
291
+ }
292
+
293
+ out[pos] = b'"';
294
+ pos += 1;
295
+ write_json_escaped(out, &mut pos, name);
296
+ out[pos] = b'"';
297
+ pos += 1;
298
+ out[pos] = b':';
299
+ pos += 1;
300
+ out[pos] = b'"';
301
+ pos += 1;
302
+ write_json_escaped(out, &mut pos, value);
303
+ out[pos] = b'"';
304
+ pos += 1;
305
+ }
306
+
307
+ if pos + 1 > out.len() {
308
+ return Err("output buffer too small for cookie JSON".to_string());
309
+ }
310
+
311
+ out[pos] = b'}';
312
+ pos += 1;
313
+ Ok(pos)
314
+ }
315
+
316
+ /// Parse packed query pairs and write JSON to `out` with max_pairs limit.
317
+ ///
318
+ /// Test-only reference oracle: the ingress hot path uses the single-pass
319
+ /// `query_to_json_into_slice` (which superseded the two-step pipeline this
320
+ /// helper represents); `unit_tests.rs` keeps it as the byte-parity reference.
321
+ #[cfg(test)]
322
+ pub fn packed_pairs_to_json_into_slice(
323
+ packed: &[u8],
324
+ out: &mut [u8],
325
+ max_pairs: usize,
326
+ ) -> std::result::Result<usize, String> {
327
+ let read_u32 = |data: &[u8], offset: usize| -> Option<u32> {
328
+ let slice = data.get(offset..offset + 4)?;
329
+ Some(u32::from_le_bytes([slice[0], slice[1], slice[2], slice[3]]))
330
+ };
331
+
332
+ if out.len() < 2 {
333
+ return Err("output buffer too small for packed pairs JSON".to_string());
334
+ }
335
+
336
+ if packed.len() < 4 {
337
+ out[0..2].copy_from_slice(b"{}");
338
+ return Ok(2);
339
+ }
340
+
341
+ let count =
342
+ read_u32(packed, 0).ok_or_else(|| "packed pairs: truncated count".to_string())? as usize;
343
+
344
+ out[0] = b'{';
345
+ let mut pos = 1usize;
346
+ let mut src = 4usize;
347
+
348
+ for (written_pairs, _) in (0..count).enumerate() {
349
+ if written_pairs >= max_pairs {
350
+ break;
351
+ }
352
+
353
+ let key_len = read_u32(packed, src)
354
+ .ok_or_else(|| "packed pairs: truncated key length".to_string())?
355
+ as usize;
356
+ src += 4;
357
+
358
+ if src + key_len > packed.len() {
359
+ return Err("packed pairs: truncated key".to_string());
360
+ }
361
+
362
+ let key = &packed[src..src + key_len];
363
+ src += key_len;
364
+
365
+ let val_len = read_u32(packed, src)
366
+ .ok_or_else(|| "packed pairs: truncated value length".to_string())?
367
+ as usize;
368
+ src += 4;
369
+
370
+ if src + val_len > packed.len() {
371
+ return Err("packed pairs: truncated value".to_string());
372
+ }
373
+
374
+ let val = &packed[src..src + val_len];
375
+ src += val_len;
376
+
377
+ let needed = (if written_pairs == 0 { 0 } else { 1 })
378
+ + 5
379
+ + json_escaped_len(key)
380
+ + json_escaped_len(val);
381
+
382
+ if needed > out.len().saturating_sub(pos) {
383
+ return Err("output buffer too small for packed pairs JSON".to_string());
384
+ }
385
+
386
+ if written_pairs != 0 {
387
+ out[pos] = b',';
388
+ pos += 1;
389
+ }
390
+
391
+ out[pos] = b'"';
392
+ pos += 1;
393
+ write_json_escaped(out, &mut pos, key);
394
+ out[pos] = b'"';
395
+ pos += 1;
396
+ out[pos] = b':';
397
+ pos += 1;
398
+ out[pos] = b'"';
399
+ pos += 1;
400
+ write_json_escaped(out, &mut pos, val);
401
+ out[pos] = b'"';
402
+ pos += 1;
403
+ }
404
+
405
+ if pos + 1 > out.len() {
406
+ return Err("output buffer too small for packed pairs JSON".to_string());
407
+ }
408
+
409
+ out[pos] = b'}';
410
+ pos += 1;
411
+ Ok(pos)
412
+ }
413
+
414
+ /// Distinct failure modes for [`query_to_json_into_slice`], so the caller can
415
+ /// map malformed input → 400 and a too-small output buffer → truncated (the
416
+ /// same split the old packed pipeline had between its parse and write steps).
417
+ #[derive(Debug, PartialEq, Eq)]
418
+ pub enum QueryJsonError {
419
+ Malformed,
420
+ BufferTooSmall,
421
+ }
422
+
423
+ /// Decode a single URL-form component (`+` → space, `%XX` → byte) into `out`.
424
+ ///
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`.
428
+ #[inline]
429
+ fn decode_query_component(
430
+ src: &[u8],
431
+ out: &mut Vec<u8>,
432
+ ) -> std::result::Result<(), QueryJsonError> {
433
+ let start = out.len();
434
+ 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
+ )?;
441
+ out.truncate(start + written);
442
+ Ok(())
443
+ }
444
+
445
+ /// Parse a URL query string and write JSON directly to `out` with a
446
+ /// `max_pairs` limit — one pass over the raw query with a single reused
447
+ /// scratch buffer, no intermediate packed buffer and no second parse.
448
+ ///
449
+ /// Semantics match the two-step `query_parse_packed_vec` +
450
+ /// `packed_pairs_to_json_into_slice` pipeline (which this replaces on the
451
+ /// ingress hot path): pairs split on `&`, `key=value` (no `=` → empty value),
452
+ /// `+`/`%XX` decoded, keys/values JSON-escaped (UTF-8-aware, identical to
453
+ /// `write_json_escaped`). `Malformed` (caller → 400) on bad `%XX`;
454
+ /// `BufferTooSmall` (caller → truncated) when `out` cannot hold the result.
455
+ #[inline]
456
+ pub fn query_to_json_into_slice(
457
+ input: &[u8],
458
+ out: &mut [u8],
459
+ max_pairs: usize,
460
+ ) -> std::result::Result<usize, QueryJsonError> {
461
+ if out.len() < 2 {
462
+ return Err(QueryJsonError::BufferTooSmall);
463
+ }
464
+
465
+ out[0] = b'{';
466
+ let mut pos = 1usize;
467
+ let mut written_pairs = 0usize;
468
+
469
+ // Shared scratch for percent-decoded components — reused across all pairs
470
+ // (grows only to the largest component; bounded by the caller's query
471
+ // limit), so there is no per-pair allocation.
472
+ let mut scratch: Vec<u8> = Vec::new();
473
+
474
+ for pair in input.split(|&b| b == b'&') {
475
+ if pair.is_empty() {
476
+ continue;
477
+ }
478
+ if written_pairs >= max_pairs {
479
+ break;
480
+ }
481
+ let (key, value) = match pair.iter().position(|&b| b == b'=') {
482
+ Some(eq) => (&pair[..eq], &pair[eq + 1..]),
483
+ None => (pair, &[][..]),
484
+ };
485
+
486
+ // Percent-encoding fast path: a component with no `%` and no `+`
487
+ // decodes to itself (Cow::Borrowed), so we skip the decode scratch and
488
+ // both extra passes, writing the borrowed slices straight to JSON. This
489
+ // is the common case (`?page=1&limit=20`-style queries) and removes one
490
+ // heap allocation + decode per pair per request on the ingress hot path.
491
+ if memchr::memchr2(b'%', b'+', pair).is_none() {
492
+ let key_len = json_escaped_len(key);
493
+ let val_len = json_escaped_len(value);
494
+
495
+ let needed = (if written_pairs == 0 { 0 } else { 1 }) + 5 + key_len + val_len;
496
+ if needed > out.len().saturating_sub(pos) {
497
+ return Err(QueryJsonError::BufferTooSmall);
498
+ }
499
+
500
+ if written_pairs != 0 {
501
+ out[pos] = b',';
502
+ pos += 1;
503
+ }
504
+ out[pos] = b'"';
505
+ pos += 1;
506
+ write_json_escaped(out, &mut pos, key);
507
+ out[pos] = b'"';
508
+ pos += 1;
509
+ out[pos] = b':';
510
+ pos += 1;
511
+ out[pos] = b'"';
512
+ pos += 1;
513
+ write_json_escaped(out, &mut pos, value);
514
+ out[pos] = b'"';
515
+ pos += 1;
516
+
517
+ written_pairs += 1;
518
+ continue;
519
+ }
520
+
521
+ // Single-pass percent-decode: decode each component ONCE into the
522
+ // shared scratch as `[decoded_key][decoded_value]`, then derive the
523
+ // escaped lengths and write from the same buffers. (The previous code
524
+ // decoded every component twice — once in a length pass, once in a
525
+ // write pass — i.e. four decodes per pair.)
526
+ scratch.clear();
527
+ decode_query_component(key, &mut scratch)?;
528
+ let key_end = scratch.len();
529
+ decode_query_component(value, &mut scratch)?;
530
+ let key_len = json_escaped_len(&scratch[..key_end]);
531
+ let val_len = json_escaped_len(&scratch[key_end..]);
532
+
533
+ let needed = (if written_pairs == 0 { 0 } else { 1 }) + 5 + key_len + val_len;
534
+ if needed > out.len().saturating_sub(pos) {
535
+ return Err(QueryJsonError::BufferTooSmall);
536
+ }
537
+
538
+ // Write pass (from the already-decoded scratch buffers).
539
+ if written_pairs != 0 {
540
+ out[pos] = b',';
541
+ pos += 1;
542
+ }
543
+ out[pos] = b'"';
544
+ pos += 1;
545
+ write_json_escaped(out, &mut pos, &scratch[..key_end]);
546
+ out[pos] = b'"';
547
+ pos += 1;
548
+ out[pos] = b':';
549
+ pos += 1;
550
+ out[pos] = b'"';
551
+ pos += 1;
552
+ write_json_escaped(out, &mut pos, &scratch[key_end..]);
553
+ out[pos] = b'"';
554
+ pos += 1;
555
+
556
+ written_pairs += 1;
557
+ }
558
+
559
+ if pos + 1 > out.len() {
560
+ return Err(QueryJsonError::BufferTooSmall);
561
+ }
562
+
563
+ out[pos] = b'}';
564
+ pos += 1;
565
+ Ok(pos)
566
+ }
567
+
568
+ /// Write the full metadata JSON envelope (requestId, path, cookies, query).
569
+ ///
570
+ /// The metadata envelope legitimately carries many fields; the flat signature
571
+ /// keeps the writer allocation-free (no options struct on the hot path).
572
+ #[allow(clippy::too_many_arguments)]
573
+ #[inline]
574
+ pub fn write_full_body_json(
575
+ out: &mut [u8],
576
+ pos: usize,
577
+ request_id: &[u8],
578
+ path: &[u8],
579
+ cookies_start: usize,
580
+ cookies_len: usize,
581
+ query_start: usize,
582
+ query_len: usize,
583
+ ) -> usize {
584
+ const P1: &[u8] = b"{\"ok\":true,\"requestId\":\"";
585
+ const P2: &[u8] = b"\",\"path\":\"";
586
+ const P3: &[u8] = b"\",\"cookies\":";
587
+ const P4: &[u8] = b",\"query\":";
588
+
589
+ let cookies_eff_len = if cookies_len > 0 { cookies_len } else { 2 };
590
+ let query_eff_len = if query_len > 0 { query_len } else { 2 };
591
+
592
+ let required = P1.len()
593
+ + json_escaped_len(request_id)
594
+ + P2.len()
595
+ + json_escaped_len(path)
596
+ + P3.len()
597
+ + cookies_eff_len
598
+ + P4.len()
599
+ + query_eff_len
600
+ + 1;
601
+
602
+ let end = match pos.checked_add(required) {
603
+ Some(v) => v,
604
+ None => return 0,
605
+ };
606
+
607
+ if end > out.len() {
608
+ return 0;
609
+ }
610
+
611
+ let mut wp = pos;
612
+ out[wp..wp + P1.len()].copy_from_slice(P1);
613
+ wp += P1.len();
614
+ write_json_escaped(out, &mut wp, request_id);
615
+ out[wp..wp + P2.len()].copy_from_slice(P2);
616
+ wp += P2.len();
617
+ write_json_escaped(out, &mut wp, path);
618
+ out[wp..wp + P3.len()].copy_from_slice(P3);
619
+ wp += P3.len();
620
+
621
+ if cookies_len > 0 {
622
+ out.copy_within(cookies_start..cookies_start + cookies_len, wp);
623
+ wp += cookies_len;
624
+ } else {
625
+ out[wp..wp + 2].copy_from_slice(b"{}");
626
+ wp += 2;
627
+ }
628
+
629
+ out[wp..wp + P4.len()].copy_from_slice(P4);
630
+ wp += P4.len();
631
+
632
+ if query_len > 0 {
633
+ out.copy_within(query_start..query_start + query_len, wp);
634
+ wp += query_len;
635
+ } else {
636
+ out[wp..wp + 2].copy_from_slice(b"{}");
637
+ wp += 2;
638
+ }
639
+
640
+ out[wp] = b'}';
641
+ wp += 1;
642
+ wp - pos
643
+ }
644
+
645
+ #[cfg(test)]
646
+ mod tests {
647
+ use super::{
648
+ cookie_json_into_slice, json_escaped_len, packed_pairs_to_json_into_slice,
649
+ query_to_json_into_slice, write_json_escaped, QueryJsonError,
650
+ };
651
+
652
+ #[test]
653
+ fn json_escaped_len_plain_ascii() {
654
+ assert_eq!(json_escaped_len(b"hello world"), 11);
655
+ }
656
+
657
+ #[test]
658
+ fn json_escaped_len_quotes_and_backslash() {
659
+ // "a\"b" -> a, \", b = 3 chars + 1 extra for the escaped quote.
660
+ assert_eq!(json_escaped_len(b"a\"b"), 4);
661
+ // backslash doubles: bytes a, \, b = 3 -> 4 escaped.
662
+ assert_eq!(json_escaped_len(b"a\\b"), 4);
663
+ }
664
+
665
+ #[test]
666
+ fn json_escaped_len_newline() {
667
+ assert_eq!(json_escaped_len(b"a\nb"), 4);
668
+ }
669
+
670
+ #[test]
671
+ fn json_escaped_len_control_char_wide() {
672
+ // 0x01 must be escaped as \u0001 -> 6 bytes for 1 input byte.
673
+ assert_eq!(json_escaped_len(&[b'a', 0x01, b'b']), 8);
674
+ }
675
+
676
+ #[test]
677
+ fn json_escaped_len_short_control_escapes() {
678
+ // \r, \t, \x08, \x0c are written as 2-byte escapes, each contributing
679
+ // +1. (memchr3 only finds ", \, \n; the run scanner handles these.)
680
+ assert_eq!(json_escaped_len(b"a\rb"), 4); // a, \r, b
681
+ assert_eq!(json_escaped_len(b"a\tb"), 4);
682
+ assert_eq!(json_escaped_len(&[b'a', 0x08, b'b']), 4);
683
+ assert_eq!(json_escaped_len(&[b'a', 0x0c, b'b']), 4);
684
+ // Mixed: newline (memchr3 path) + tab (trailing path).
685
+ assert_eq!(json_escaped_len(b"a\n\tb"), 6);
686
+ }
687
+
688
+ #[test]
689
+ fn json_escaped_len_fused_matches_write_on_utf8_corpus() {
690
+ // The fused len (single pass: memchr3 + gap UTF-8 validation) must exactly
691
+ // equal the bytes `write_json_escaped` emits for valid non-ASCII UTF-8 AND
692
+ // invalid UTF-8 (where every byte becomes \u00XX).
693
+ let cases: &[&[u8]] = &[
694
+ "héllo wörld".as_bytes(), // valid non-ASCII, no escapes
695
+ "caf\u{00e9} \u{201c}quoted\u{201d}".as_bytes(), // valid with escapes
696
+ &[b'a', 0xC3, 0xA9, b'b'], // é valid
697
+ &[b'"', 0xC3, 0xA9, b'\\'], // escapes + multibyte
698
+ &[0xFF, 0xFE, b'a'], // invalid UTF-8
699
+ &[b'a', 0x80, b'b'], // lone continuation byte
700
+ &[0xC3, b' ', b'x'], // truncated multibyte
701
+ &[b'a', b'\\', 0xFF, b'\n', 0x01], // mixed invalid + escapes
702
+ "日本語のテキスト".as_bytes(), // pure multibyte, no ASCII
703
+ ];
704
+ for input in cases {
705
+ let len = json_escaped_len(input);
706
+ let mut out = vec![0u8; len];
707
+ let mut pos = 0usize;
708
+ write_json_escaped(&mut out, &mut pos, input);
709
+ assert_eq!(pos, len, "len must match written for {input:?}");
710
+ }
711
+ }
712
+
713
+ #[test]
714
+ fn json_escaped_len_invalid_utf8_is_len_times_six() {
715
+ let cases: &[&[u8]] = &[
716
+ &[0xFF, 0xFE],
717
+ &[b'a', 0x80, b'b'],
718
+ &[0xC3, b' '],
719
+ &[b'a', b'\\', 0xFF],
720
+ ];
721
+ for input in cases {
722
+ let len = json_escaped_len(input);
723
+ assert_eq!(len, input.len() * 6, "input: {input:?}");
724
+ }
725
+ }
726
+
727
+ #[test]
728
+ fn write_json_escaped_never_overflows_exact_buffer() {
729
+ // Regression: a buffer sized EXACTLY by json_escaped_len must never
730
+ // overflow. Before the fix, \r/\t/\x08/\x0c were undercounted → the write
731
+ // past the end panicked (caught by napi → 500) or corrupted memory.
732
+ let cases: &[&[u8]] = &[
733
+ b"a\rb",
734
+ b"a\tb",
735
+ &[b'a', 0x08, b'b'],
736
+ &[b'a', 0x0c, b'b'],
737
+ b"cookie=1; other=2\r\n\t",
738
+ b"\t\r\x08\x0c\"\\\n\x01\x1f",
739
+ ];
740
+
741
+ for input in cases {
742
+ let len = json_escaped_len(input);
743
+ let mut out = vec![0u8; len];
744
+ let mut pos = 0usize;
745
+ write_json_escaped(&mut out, &mut pos, input);
746
+ assert_eq!(
747
+ pos, len,
748
+ "must write exactly json_escaped_len bytes: {input:?}"
749
+ );
750
+ }
751
+ }
752
+
753
+ #[test]
754
+ fn write_json_escaped_escapes_control_before_special() {
755
+ // Regression: control chars that appear BEFORE a memchr3 special (", \,
756
+ // \n) must still be escaped. Previously they were copied raw into the JSON
757
+ // string → RFC-8259-invalid output (e.g. a cookie value `a\tb"c` or a URL
758
+ // query `?q=%09%22`). The length accounting must match the write exactly.
759
+ let cases: &[(&[u8], &[u8])] = &[
760
+ // a\rb"c → a \\r b \"
761
+ (
762
+ b"a\rb\"c",
763
+ b"a\\rb\\\"c", // \r → \\r, " → \"
764
+ ),
765
+ // a\tb\\c → a \t b \ \
766
+ (
767
+ b"a\tb\\c",
768
+ b"a\\tb\\\\c", // \t → \\t, \ → \\
769
+ ),
770
+ // 0x01 before \n → \u0001 then \n
771
+ (b"a\x01b\nc", b"a\\u0001b\\nc"),
772
+ ];
773
+
774
+ for (input, expected) in cases {
775
+ let len = json_escaped_len(input);
776
+ let mut out = vec![0u8; len];
777
+ let mut pos = 0usize;
778
+ write_json_escaped(&mut out, &mut pos, input);
779
+ assert_eq!(pos, len, "accounting must be exact for {input:?}");
780
+ assert_eq!(
781
+ &out[..pos],
782
+ *expected,
783
+ "output must be valid JSON for {input:?}"
784
+ );
785
+ }
786
+ }
787
+
788
+ #[test]
789
+ fn cookie_json_into_slice_short_control_escapes() {
790
+ // Cookie values containing \r/\t must serialize correctly into a buffer
791
+ // sized by the (fixed) length accounting.
792
+ let mut out = vec![0u8; 256];
793
+ let written = cookie_json_into_slice(b"a=va\tl; b=x\ry", &mut out, 100).unwrap();
794
+ assert_eq!(&out[..written], b"{\"a\":\"va\\tl\",\"b\":\"x\\ry\"}");
795
+ }
796
+
797
+ #[test]
798
+ fn cookie_json_into_slice_output() {
799
+ let mut out = vec![0u8; 256];
800
+ let written = cookie_json_into_slice(b"a=1; b=hello world", &mut out, 100).unwrap();
801
+ assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"hello world\"}");
802
+ }
803
+
804
+ #[test]
805
+ fn cookie_json_into_slice_unwraps_dquote() {
806
+ let mut out = vec![0u8; 256];
807
+ // RFC 6265 §5.2: a DQUOTE-wrapped cookie value is unwrapped before
808
+ // serializing (matches the JS fallback + the native cookie parser).
809
+ let written = cookie_json_into_slice(b"k=\"v\"", &mut out, 100).unwrap();
810
+ assert_eq!(&out[..written], b"{\"k\":\"v\"}");
811
+ // JSON escaping still applies to unquoted values with special characters.
812
+ let written = cookie_json_into_slice(b"k=a\"b", &mut out, 100).unwrap();
813
+ assert_eq!(&out[..written], b"{\"k\":\"a\\\"b\"}");
814
+ }
815
+
816
+ #[test]
817
+ fn cookie_json_into_slice_small_buffer_errors() {
818
+ let mut out = vec![0u8; 8];
819
+ let res = cookie_json_into_slice(b"a=1; b=2; c=3; d=4", &mut out, 100);
820
+ assert!(
821
+ res.is_err(),
822
+ "truncation must surface as an error, not silent data loss"
823
+ );
824
+ }
825
+
826
+ #[test]
827
+ fn packed_pairs_to_json_into_slice_output() {
828
+ // Build packed query pairs for a=1 & b=2 via the query parser, then serialize.
829
+ let packed = crate::http::query_parser::query_parse_packed_vec(b"a=1&b=2").unwrap();
830
+ let mut out = vec![0u8; 256];
831
+ let written = packed_pairs_to_json_into_slice(&packed, &mut out, 100).unwrap();
832
+ assert_eq!(&out[..written], b"{\"a\":\"1\",\"b\":\"2\"}");
833
+ }
834
+
835
+ #[test]
836
+ fn query_to_json_into_slice_matches_packed_pipeline() {
837
+ // The direct writer must produce byte-identical output to the two-step
838
+ // query_parse_packed_vec + packed_pairs_to_json_into_slice pipeline it
839
+ // replaces on the ingress hot path.
840
+ let cases: &[&[u8]] = &[
841
+ b"a=1&b=2",
842
+ b"name=John%20Doe&q=a+b",
843
+ b"flag",
844
+ b"",
845
+ b"x=%41%42",
846
+ b"a=%ZZ",
847
+ b"k=%E2%82%AC", // euro (valid UTF-8 after decode)
848
+ b"weird=%FF", // invalid UTF-8 after decode (binary escape path)
849
+ b"a=1&a=2&a=3",
850
+ b"spaces=+a+b+c+",
851
+ ];
852
+ for &raw in cases {
853
+ let packed = crate::http::query_parser::query_parse_packed_vec(raw);
854
+ let mut direct_out = vec![0u8; 512];
855
+ let direct = query_to_json_into_slice(raw, &mut direct_out, 100);
856
+ match (&packed, &direct) {
857
+ (Ok(packed), Ok(written)) => {
858
+ let mut ref_out = vec![0u8; 512];
859
+ let ref_written =
860
+ packed_pairs_to_json_into_slice(packed, &mut ref_out, 100).unwrap();
861
+ assert_eq!(
862
+ &direct_out[..*written],
863
+ &ref_out[..ref_written],
864
+ "query={raw:?}"
865
+ );
866
+ }
867
+ (Err(_), Err(QueryJsonError::Malformed)) => {} // both reject malformed %XX
868
+ (other, _) => {
869
+ panic!("mismatched outcome for query={raw:?}: {other:?} vs {direct:?}")
870
+ }
871
+ }
872
+ }
873
+
874
+ // Buffer-too-small must surface as BufferTooSmall (→ truncated), not Malformed.
875
+ let mut tiny = vec![0u8; 2];
876
+ assert!(matches!(
877
+ query_to_json_into_slice(b"a=1", &mut tiny, 100),
878
+ Err(QueryJsonError::BufferTooSmall)
879
+ ));
880
+ }
881
+ }