castrum 0.9.3 → 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 (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,594 @@
1
+ // rust/util/bytes.rs — Shared low-level byte utilities
2
+ //
3
+ // This is the ONE home for the small byte-manipulation primitives that are
4
+ // reused across the crate: word-at-a-time comparison, hex (de)encoding,
5
+ // `%XX` percent decoding, whitespace trimming, and cookie pair splitting.
6
+ //
7
+ // Before the refactor these helpers were copy-pasted into several modules
8
+ // (headers.rs, method.rs, hmac_sha256.rs, random_token.rs, url_codec.rs,
9
+ // query_parser.rs, cookie_parser.rs, json_ser.rs). Keeping them in a single
10
+ // place means there is exactly ONE implementation to read, test, and tune.
11
+
12
+ // ── Word-at-a-time comparison ──────────────────────────────────────
13
+
14
+ /// Load a `u64` from the first `min(len, 8)` bytes of `bytes`, zero-padded.
15
+ #[inline(always)]
16
+ pub fn load_u64_padded(bytes: &[u8]) -> u64 {
17
+ let mut buf = [0u8; 8];
18
+ let len = bytes.len().min(8);
19
+ buf[..len].copy_from_slice(&bytes[..len]);
20
+ u64::from_le_bytes(buf)
21
+ }
22
+
23
+ /// ASCII case-insensitive comparison using u64 words for short inputs.
24
+ ///
25
+ /// Both inputs must have the same length. The `0x20` bit is OR'd into both
26
+ /// words so `A`..`Z` and `a`..`z` collapse to the same value — no allocation.
27
+ ///
28
+ /// Note: only safe for ASCII name matching (headers, HTTP methods), never for
29
+ /// arbitrary binary data (the `0x20` mask can equate some non-alpha chars).
30
+ #[inline(always)]
31
+ pub fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
32
+ if a.len() != b.len() {
33
+ return false;
34
+ }
35
+
36
+ match a.len() {
37
+ 0 => true,
38
+ 1..=8 => {
39
+ let wa = load_u64_padded(a) | 0x2020_2020_2020_2020;
40
+ let wb = load_u64_padded(b) | 0x2020_2020_2020_2020;
41
+ wa == wb
42
+ }
43
+ 9..=16 => {
44
+ let wa = load_u64_padded(a) | 0x2020_2020_2020_2020;
45
+ let wb = load_u64_padded(b) | 0x2020_2020_2020_2020;
46
+ if wa != wb {
47
+ return false;
48
+ }
49
+ let wa2 = load_u64_padded(&a[8..]) | 0x2020_2020_2020_2020;
50
+ let wb2 = load_u64_padded(&b[8..]) | 0x2020_2020_2020_2020;
51
+ wa2 == wb2
52
+ }
53
+ _ => a.eq_ignore_ascii_case(b),
54
+ }
55
+ }
56
+
57
+ // ── Hex (de)encoding ───────────────────────────────────────────────
58
+
59
+ pub const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
60
+ pub const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
61
+
62
+ /// 256-entry hex-digit lookup (single table load instead of a range-compare
63
+ /// chain); -1 marks a non-hex byte. Shared by all `%XX` / hex decode paths.
64
+ const HEX_VAL_LUT: [i8; 256] = {
65
+ let mut t = [-1i8; 256];
66
+ let mut i = 0usize;
67
+ while i < 256 {
68
+ let b = i as u8;
69
+ t[i] = match b {
70
+ b'0'..=b'9' => (b - b'0') as i8,
71
+ b'a'..=b'f' => (b - b'a' + 10) as i8,
72
+ b'A'..=b'F' => (b - b'A' + 10) as i8,
73
+ _ => -1,
74
+ };
75
+ i += 1;
76
+ }
77
+ t
78
+ };
79
+
80
+ /// Value of a single hex digit, or `None` if `b` is not a hex digit.
81
+ #[inline(always)]
82
+ pub fn hex_val(b: u8) -> Option<u8> {
83
+ let v = HEX_VAL_LUT[b as usize];
84
+ if v < 0 {
85
+ None
86
+ } else {
87
+ Some(v as u8)
88
+ }
89
+ }
90
+
91
+ /// Encode `bytes` as lowercase hex into `out`, which must hold `2 * bytes.len()`.
92
+ #[inline]
93
+ pub fn hex_encode(bytes: &[u8], out: &mut [u8]) {
94
+ // Checked (not debug-only): with `debug-assertions = false` an undersized
95
+ // `out` would otherwise surface as an unhelpful index panic in release.
96
+ assert!(
97
+ out.len() >= bytes.len() * 2,
98
+ "hex_encode: output buffer too small ({} < {})",
99
+ out.len(),
100
+ bytes.len() * 2
101
+ );
102
+ for (i, &b) in bytes.iter().enumerate() {
103
+ out[2 * i] = HEX_LOWER[(b >> 4) as usize];
104
+ out[2 * i + 1] = HEX_LOWER[(b & 0x0f) as usize];
105
+ }
106
+ }
107
+
108
+ /// Encode exactly 32 bytes as lowercase hex into a fixed 64-byte output.
109
+ #[inline(always)]
110
+ pub fn hex_encode_32(bytes: &[u8], out: &mut [u8; 64]) {
111
+ debug_assert_eq!(bytes.len(), 32);
112
+ hex_encode(bytes, out);
113
+ }
114
+
115
+ /// Decode exactly 64 hex characters into 32 bytes. Returns `None` on bad input.
116
+ #[inline]
117
+ pub fn hex_decode_32(hex: &[u8]) -> Option<[u8; 32]> {
118
+ if hex.len() != 64 {
119
+ return None;
120
+ }
121
+ let mut out = [0u8; 32];
122
+ for (i, slot) in out.iter_mut().enumerate() {
123
+ let hi = hex_val(hex[2 * i])?;
124
+ let lo = hex_val(hex[2 * i + 1])?;
125
+ *slot = (hi << 4) | lo;
126
+ }
127
+ Some(out)
128
+ }
129
+
130
+ // ── Percent decoding ───────────────────────────────────────────────
131
+
132
+ /// Decode the `%XX` sequence starting at `src[i..]` into a single byte.
133
+ ///
134
+ /// Returns `(decoded_byte, index_of_next_input_byte)`, or `None` when the
135
+ /// sequence is malformed (truncated or a non-hex digit).
136
+ #[inline]
137
+ pub fn decode_percent_at(src: &[u8], i: usize) -> Option<(u8, usize)> {
138
+ if i + 2 >= src.len() {
139
+ return None;
140
+ }
141
+ let hi = hex_val(src[i + 1])?;
142
+ let lo = hex_val(src[i + 2])?;
143
+ Some(((hi << 4) | lo, i + 3))
144
+ }
145
+
146
+ // ── Form-component decoding ────────────────────────────────────────
147
+
148
+ /// Decode failure modes for the form-component decoders.
149
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
150
+ pub enum FormDecodeError {
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).
156
+ Malformed,
157
+ /// `out` cannot hold the decoded result.
158
+ BufferTooSmall,
159
+ }
160
+
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:
173
+ ///
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).
180
+ ///
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.
192
+ #[inline]
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.
197
+ if memchr::memchr2(b'+', b'%', src).is_none() {
198
+ if out.len() < src.len() {
199
+ return Err(FormDecodeError::BufferTooSmall);
200
+ }
201
+ out[..src.len()].copy_from_slice(src);
202
+ return Ok(src.len());
203
+ }
204
+ // Run-copy decode: bulk-copy plain runs up to the next '+'/'%', then
205
+ // decode the single special byte, instead of walking every byte. This is
206
+ // the same memchr2 discipline `url_decode` uses and avoids per-byte
207
+ // branches over the common long runs of unreserved characters. Capacity
208
+ // is still checked against the ACTUAL decoded length (a `%XX` shrinks 3
209
+ // input bytes to 1), preserving the existing semantics.
210
+ let mut i = 0usize;
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;
215
+ while i < src.len() {
216
+ match memchr::memchr2(b'+', b'%', &src[i..]) {
217
+ Some(rel) => {
218
+ let run_end = i + rel;
219
+ let run = &src[i..run_end];
220
+ if written + run.len() > out.len() {
221
+ return Err(FormDecodeError::BufferTooSmall);
222
+ }
223
+ out[written..written + run.len()].copy_from_slice(run);
224
+ saw_high |= !run.is_ascii();
225
+ written += run.len();
226
+ i = run_end;
227
+
228
+ // Decode the '+' or '%XX' at position i.
229
+ let (b, next) = match src[i] {
230
+ b'+' => (b' ', i + 1),
231
+ _ => {
232
+ let (byte, next) =
233
+ decode_percent_at(src, i).ok_or(FormDecodeError::Malformed)?;
234
+ (byte, next)
235
+ }
236
+ };
237
+ if written >= out.len() {
238
+ return Err(FormDecodeError::BufferTooSmall);
239
+ }
240
+ out[written] = b;
241
+ saw_high |= b >= 0x80;
242
+ written += 1;
243
+ i = next;
244
+ }
245
+ None => {
246
+ let run = &src[i..];
247
+ if written + run.len() > out.len() {
248
+ return Err(FormDecodeError::BufferTooSmall);
249
+ }
250
+ out[written..written + run.len()].copy_from_slice(run);
251
+ saw_high |= !run.is_ascii();
252
+ written += run.len();
253
+ i = src.len();
254
+ }
255
+ }
256
+ }
257
+ if saw_high && !is_valid_utf8(&out[..written]) {
258
+ return Err(FormDecodeError::Malformed);
259
+ }
260
+ Ok(written)
261
+ }
262
+
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.
275
+ ///
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.
289
+ #[inline]
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 {
317
+ if memchr::memchr2(b'+', b'%', src).is_none() {
318
+ return src.len();
319
+ }
320
+ let mut i = 0usize;
321
+ let mut written = 0usize;
322
+ let mut saw_high = false;
323
+ while i < src.len() {
324
+ match memchr::memchr2(b'+', b'%', &src[i..]) {
325
+ Some(rel) => {
326
+ let run_end = i + rel;
327
+ written += rel;
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
+ }
346
+ }
347
+ None => {
348
+ written += src.len() - i;
349
+ saw_high |= !src[i..].is_ascii();
350
+ i = src.len();
351
+ }
352
+ }
353
+ }
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
+ }
381
+ }
382
+
383
+ // ── Whitespace + cookie splitting ──────────────────────────────────
384
+
385
+ /// Trim ASCII whitespace from both ends of a byte slice.
386
+ #[inline(always)]
387
+ pub fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
388
+ let mut start = 0usize;
389
+ let mut end = bytes.len();
390
+
391
+ while start < end && bytes[start].is_ascii_whitespace() {
392
+ start += 1;
393
+ }
394
+
395
+ while end > start && bytes[end - 1].is_ascii_whitespace() {
396
+ end -= 1;
397
+ }
398
+
399
+ &bytes[start..end]
400
+ }
401
+
402
+ /// Iterate over the `name=value` pairs in a cookie-style header.
403
+ ///
404
+ /// Pairs are split on `;`, whitespace-trimmed, and entries with an empty name
405
+ /// are skipped. A missing `=` yields an empty value. A value wrapped in
406
+ /// surrounding double quotes is unwrapped (RFC 6265 §5.2) so the native
407
+ /// output is byte-identical to the JS fallback.
408
+ pub fn cookie_pairs(input: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> + '_ {
409
+ input.split(|&b| b == b';').filter_map(|raw| {
410
+ let pair = trim_ascii_whitespace(raw);
411
+ if pair.is_empty() {
412
+ return None;
413
+ }
414
+
415
+ let (name, value) = match pair.iter().position(|&b| b == b'=') {
416
+ Some(eq) => (&pair[..eq], &pair[eq + 1..]),
417
+ None => (pair, &[][..]),
418
+ };
419
+
420
+ let name = trim_ascii_whitespace(name);
421
+ let value = trim_ascii_whitespace(value);
422
+
423
+ if name.is_empty() {
424
+ return None;
425
+ }
426
+
427
+ // RFC 6265 §5.2: unwrap a cookie-value surrounded by DQUOTE (only when
428
+ // BOTH ends quote, matching the JS fallback's `unquote` exactly).
429
+ let value = if value.len() >= 2 && value[0] == b'"' && value[value.len() - 1] == b'"' {
430
+ &value[1..value.len() - 1]
431
+ } else {
432
+ value
433
+ };
434
+
435
+ Some((name, value))
436
+ })
437
+ }
438
+
439
+ #[cfg(test)]
440
+ mod tests {
441
+ use super::*;
442
+
443
+ fn decode(src: &[u8]) -> Vec<u8> {
444
+ let mut out = vec![0u8; src.len()];
445
+ let n = decode_form_component_into(src, &mut out).unwrap();
446
+ out.truncate(n);
447
+ out
448
+ }
449
+
450
+ #[test]
451
+ fn decode_form_plain_passthrough() {
452
+ assert_eq!(decode(b"abc"), b"abc");
453
+ assert_eq!(decode(b""), b"");
454
+ // The fast path copies exactly; verify capacity via the full slice.
455
+ let mut out = [0u8; 3];
456
+ assert_eq!(decode_form_component_into(b"abc", &mut out), Ok(3));
457
+ }
458
+
459
+ #[test]
460
+ fn decode_form_plus_and_percent() {
461
+ assert_eq!(decode(b"a+b"), b"a b");
462
+ assert_eq!(decode(b"%41%42"), b"AB");
463
+ assert_eq!(decode(b"q=%E2%9C%93"), b"q=\xE2\x9C\x93");
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"/");
468
+ }
469
+
470
+ #[test]
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
+ }
557
+ }
558
+
559
+ #[test]
560
+ fn cookie_pairs_unwraps_dquote() {
561
+ let pairs: Vec<(&[u8], &[u8])> =
562
+ cookie_pairs(b"a=1; b=\"quoted value\"; c=\"\"; d=\"abc; empty=").collect();
563
+ assert_eq!(
564
+ pairs,
565
+ vec![
566
+ (&b"a"[..], &b"1"[..]),
567
+ (&b"b"[..], &b"quoted value"[..]),
568
+ (&b"c"[..], &b""[..]),
569
+ (&b"d"[..], &b"\"abc"[..]), // unbalanced quote kept, matches JS
570
+ (&b"empty"[..], &b""[..]),
571
+ ]
572
+ );
573
+ }
574
+
575
+ #[test]
576
+ fn cookie_pairs_keeps_unbalanced_quotes() {
577
+ // A quote only at one end (or embedded) is left as-is — matches JS.
578
+ let pairs: Vec<(&[u8], &[u8])> = cookie_pairs(b"a=\"abc; b=ab\"c; c=\"quote").collect();
579
+ assert_eq!(
580
+ pairs,
581
+ vec![
582
+ (&b"a"[..], &b"\"abc"[..]),
583
+ (&b"b"[..], &b"ab\"c"[..]),
584
+ (&b"c"[..], &b"\"quote"[..]),
585
+ ]
586
+ );
587
+ }
588
+ #[test]
589
+ #[should_panic(expected = "hex_encode: output buffer too small")]
590
+ fn hex_encode_panics_on_undersized() {
591
+ let mut out = [0u8; 4];
592
+ super::hex_encode(b"abcd", &mut out);
593
+ }
594
+ }
@@ -0,0 +1,34 @@
1
+ //! Shared infrastructure (no napi types in core signatures).
2
+ //
3
+ // Task-focused modules (no napi types in core signatures):
4
+ // - bytes.rs byte primitives (word-compare, hex, %XX decode, trim, cookie_pairs)
5
+ // - packed.rs zero-alloc packed iterators + byte writers
6
+ // - batch/ packed aggregate batch helpers (core.rs routing + api.rs napi)
7
+ // - batch_core.rs generic rayon-parallel batch helpers
8
+ // - threadpool.rs rayon global pool init + parallelism heuristics
9
+ // - validation.rs email / UUID / IPv4 / IPv6 + batch fixed-width hex validators
10
+ // - text.rs text utilities (JS-RegExp metacharacter escaping)
11
+ //
12
+ // The `pub use` re-exports below keep existing `crate::util::*` call sites and
13
+ // the napi JS exports (`initThreadPool` / `rayonNumThreads`) working
14
+ // unchanged. Prefer importing from the specific submodules in new code.
15
+
16
+ pub mod batch;
17
+ pub mod batch_core;
18
+ pub mod bytes;
19
+ pub mod packed;
20
+ pub mod text;
21
+ pub mod threadpool;
22
+ pub mod validation;
23
+
24
+ // Re-exported byte primitives so existing callers keep working; the canonical
25
+ // implementations live in `bytes.rs`.
26
+ pub use self::bytes::trim_ascii_whitespace;
27
+
28
+ pub(crate) use self::batch::{run_bitset_batch, run_packed_batch, run_packed_batch_idx};
29
+ pub use self::batch_core::{count_batch, sum_batch_i64, validation_bitset_chunked};
30
+ pub use self::packed::{
31
+ ensure_capacity, run_packed_into, slices_overlap, total_bytes, unpack, write_bitset_batch_into,
32
+ write_bytes, write_bytes_lowercase, write_sum_batch_into, write_u32_batch_into, write_u32_le,
33
+ };
34
+ pub use self::threadpool::{init_thread_pool, rayon_num_threads, should_parallelize};