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,770 @@
1
+ // rust/ingress/native_route.rs — Per-route native stack (`castrum_route_*`).
2
+ //
3
+ // The wire contract consumed by `@ignex/native` (the "ignex" framework): a
4
+ // route plan is compiled ONCE into a pre-baked `NativeRoute` (parse flags,
5
+ // size limits, and the draft-07 body schema compiled as `IngressSchema`), then
6
+ // each request packs a tiny frame (query substring + Cookie header + body
7
+ // bytes) and gets a packed verdict result in ONE native call.
8
+ //
9
+ // This is the live external contract that supersedes the deleted `rust/route.rs`
10
+ // (dead external-project wire). The wire is pinned on the JS side by
11
+ // `@ignex/native/src/route-wire.ts` (magic `ROUT`, version 3) and the lenient
12
+ // parse parity by `scripts/verify-native-route.ts`; stage tags MUST match
13
+ // `ROUTE_STAGE_TAG` there:
14
+ // parseQuery=0, parseCookies=1, validateQuery=2, validateCookies=3,
15
+ // validateBody=4, requireJsonBody=5.
16
+ //
17
+ // Semantics (first-failure-wins, in stage order — matching the compiled JS
18
+ // prelude): a non-JSON body under `requireJsonBody` → errorCode 400; a body
19
+ // failing its schema under `validateBody` → errorCode 422. Parse stages are
20
+ // LENIENT (byte-parity with ignex's `decodePairList`): malformed `%ZZ` and
21
+ // invalid-UTF-8 `%FF` pass through raw, `+` → space, `%2B` → literal `+`;
22
+ // cookies are trimmed + DQUOTE-unwrapped but NOT URL-decoded.
23
+ //
24
+ // Result wire (needed-size convention — `0` = real error, `> out.len` = exact
25
+ // required size): `[flags u32][errorCode u32]` + a query pair section iff
26
+ // `parseQuery` + a cookie pair section iff `parseCookies`. Pair sections are
27
+ // `[count u32] { [nameLen u32][name][valueLen u32][value] }`.
28
+
29
+ use std::sync::Arc;
30
+
31
+ use super::packed::{read_section, read_u32_at};
32
+ use super::IngressSchema;
33
+ use crate::util::bytes::cookie_pairs;
34
+
35
+ // ── Wire constants (MUST match @ignex/native route-wire.ts) ──────
36
+ /// Magic that identifies a route descriptor (`"ROUT"` LE).
37
+ pub(crate) const ROUTE_DESC_MAGIC: u32 = 0x524f5554;
38
+ /// Wire version — bump on ANY layout change (descriptor, frame, or result).
39
+ pub(crate) const ROUTE_DESC_VERSION: u32 = 3;
40
+
41
+ /// Frame flag: the body section is present (bit 0 of the frame flags word).
42
+ pub(crate) const ROUTE_FRAME_FLAG_HAS_BODY: u32 = 1 << 0;
43
+
44
+ /// Result flag: the route stack succeeded (else `errorCode` is meaningful).
45
+ pub(crate) const ROUTE_RESULT_FLAG_OK: u32 = 1 << 0;
46
+ /// Result flag: the body parsed as well-formed JSON.
47
+ pub(crate) const ROUTE_RESULT_FLAG_BODY_VALID_JSON: u32 = 1 << 1;
48
+ /// Result flag: the parsed query satisfied its schema / limits.
49
+ pub(crate) const ROUTE_RESULT_FLAG_QUERY_VALID: u32 = 1 << 2;
50
+ /// Result flag: the parsed cookies satisfied their schema / limits.
51
+ pub(crate) const ROUTE_RESULT_FLAG_COOKIE_VALID: u32 = 1 << 3;
52
+ /// Result flag: the body satisfied its schema (when one exists).
53
+ pub(crate) const ROUTE_RESULT_FLAG_BODY_VALID: u32 = 1 << 4;
54
+ // Wire-contract bits for params/headers validation. This stack never sets them
55
+ // (the compiler only emits parse/body stages), but they MUST stay defined so
56
+ // the result layout matches @ignex/native route-wire.ts.
57
+ #[allow(dead_code)]
58
+ pub(crate) const ROUTE_RESULT_FLAG_PARAMS_VALID: u32 = 1 << 5;
59
+ #[allow(dead_code)]
60
+ pub(crate) const ROUTE_RESULT_FLAG_HEADERS_VALID: u32 = 1 << 6;
61
+
62
+ /// Descriptor stage tags (the ordered pipeline a route instance runs).
63
+ pub(crate) const STAGE_PARSE_QUERY: u8 = 0;
64
+ pub(crate) const STAGE_PARSE_COOKIES: u8 = 1;
65
+ pub(crate) const STAGE_VALIDATE_QUERY: u8 = 2;
66
+ pub(crate) const STAGE_VALIDATE_COOKIES: u8 = 3;
67
+ pub(crate) const STAGE_VALIDATE_BODY: u8 = 4;
68
+ pub(crate) const STAGE_REQUIRE_JSON_BODY: u8 = 5;
69
+
70
+ /// Descriptor part tags (`RoutePartKind`): the schema-bearing request parts.
71
+ const PART_BODY: u8 = 3;
72
+
73
+ /// Body-rejected error codes reported in the result header (0 = ok).
74
+ const ERR_BODY_NOT_JSON: u32 = 400;
75
+ const ERR_BODY_SCHEMA: u32 = 422;
76
+
77
+ /// A compiled, pre-baked per-route native stack.
78
+ pub(crate) struct NativeRoute {
79
+ parse_query: bool,
80
+ parse_cookies: bool,
81
+ require_json_body: bool,
82
+ validate_body: bool,
83
+ max_body_bytes: usize,
84
+ max_query_bytes: usize,
85
+ max_cookie_bytes: usize,
86
+ max_pairs: usize,
87
+ /// Compiled draft-07 body schema (fast_schema + jsonschema dual).
88
+ body_schema: Option<Arc<IngressSchema>>,
89
+ }
90
+
91
+ impl NativeRoute {
92
+ /// Compile a route plan from its descriptor wire. Returns a human-readable
93
+ /// error string on a malformed/unsupported descriptor (bad magic/version,
94
+ /// unknown stage/part tag, a query/cookie/params/headers schema — the
95
+ /// current stack validates the BODY only, so a non-body schema is an
96
+ /// unsupported feature → the caller falls back to JS, byte-parity
97
+ /// preserved by design).
98
+ pub(crate) fn compile(desc: &[u8]) -> std::result::Result<Self, String> {
99
+ let mut pos = 0usize;
100
+ let magic = read_u32_at(desc, &mut pos)?;
101
+ if (magic as u32) != ROUTE_DESC_MAGIC {
102
+ return Err(format!("route descriptor: bad magic 0x{magic:08x}"));
103
+ }
104
+ let version = read_u32_at(desc, &mut pos)?;
105
+ if (version as u32) != ROUTE_DESC_VERSION {
106
+ return Err(format!(
107
+ "route descriptor: unsupported version {version} (this build supports {ROUTE_DESC_VERSION})"
108
+ ));
109
+ }
110
+ let max_body_bytes = read_u32_at(desc, &mut pos)?;
111
+ let max_query_bytes = read_u32_at(desc, &mut pos)?;
112
+ let max_cookie_bytes = read_u32_at(desc, &mut pos)?;
113
+ let max_pairs = read_u32_at(desc, &mut pos)?;
114
+
115
+ let mut parse_query = false;
116
+ let mut parse_cookies = false;
117
+ let mut require_json_body = false;
118
+ let mut validate_body = false;
119
+
120
+ let stage_count = read_u32_at(desc, &mut pos)?;
121
+ for _ in 0..stage_count {
122
+ let tag = *desc
123
+ .get(pos)
124
+ .ok_or_else(|| "route descriptor: truncated stage list".to_string())?;
125
+ pos += 1;
126
+ match tag {
127
+ STAGE_PARSE_QUERY => parse_query = true,
128
+ STAGE_PARSE_COOKIES => parse_cookies = true,
129
+ // validateQuery/validateCookies are no-ops in this stack (the
130
+ // parse VALID bit is the verdict); a schema for them would have
131
+ // been rejected below.
132
+ STAGE_VALIDATE_QUERY => {}
133
+ STAGE_VALIDATE_COOKIES => {}
134
+ STAGE_VALIDATE_BODY => validate_body = true,
135
+ STAGE_REQUIRE_JSON_BODY => require_json_body = true,
136
+ other => {
137
+ return Err(format!("route descriptor: unknown stage tag {other}"));
138
+ }
139
+ }
140
+ }
141
+
142
+ let schema_count = read_u32_at(desc, &mut pos)?;
143
+ let mut body_schema_bytes: Option<Vec<u8>> = None;
144
+ for _ in 0..schema_count {
145
+ let part = *desc
146
+ .get(pos)
147
+ .ok_or_else(|| "route descriptor: truncated schema list".to_string())?;
148
+ pos += 1;
149
+ let len = read_u32_at(desc, &mut pos)?;
150
+ let end = pos
151
+ .checked_add(len)
152
+ .ok_or_else(|| "route descriptor: schema length overflow".to_string())?;
153
+ if end > desc.len() {
154
+ return Err("route descriptor: truncated schema".to_string());
155
+ }
156
+ let bytes = &desc[pos..end];
157
+ pos = end;
158
+ match part {
159
+ PART_BODY => body_schema_bytes = Some(bytes.to_vec()),
160
+ other => {
161
+ // This stack validates the BODY only. A schema for any other
162
+ // part (params/query/cookie/headers/response) is unsupported
163
+ // → fail compilation so the caller falls back to JS
164
+ // (byte-parity preserved by design). validateQuery /
165
+ // validateCookies WITHOUT a schema are a no-op (the parse
166
+ // VALID bit is the verdict), so they never reach this check.
167
+ return Err(format!(
168
+ "route descriptor: unsupported schema part tag {other} (this stack validates the body only)"
169
+ ));
170
+ }
171
+ }
172
+ }
173
+
174
+ let body_schema = match body_schema_bytes {
175
+ Some(bytes) => {
176
+ let schema_str = std::str::from_utf8(&bytes)
177
+ .map_err(|_| "route descriptor: body schema is not valid UTF-8".to_string())?;
178
+ let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
179
+ .map_err(|e| format!("route descriptor: body schema JSON error: {e}"))?;
180
+ let compiled = IngressSchema::compile(&schema_value)
181
+ .map_err(|e| format!("route descriptor: body schema compile error: {e}"))?;
182
+ Some(Arc::new(compiled))
183
+ }
184
+ None => None,
185
+ };
186
+
187
+ Ok(Self {
188
+ parse_query,
189
+ parse_cookies,
190
+ require_json_body,
191
+ validate_body,
192
+ max_body_bytes,
193
+ max_query_bytes,
194
+ max_cookie_bytes,
195
+ max_pairs,
196
+ body_schema,
197
+ })
198
+ }
199
+
200
+ /// Run the pre-baked stack for one request frame, writing the packed
201
+ /// result into `out`. Returns `Ok(n)` where `n` is the number of bytes
202
+ /// written when `out` is large enough, OR the exact required size when it
203
+ /// is too small (the needed-size convention: `0` = real error, `> out.len`
204
+ /// = exact required). `Err` = malformed frame (→ 0 on the C ABI).
205
+ ///
206
+ /// Pure: `&self`, no interior mutability — safe for concurrent use.
207
+ pub(crate) fn run(&self, frame: &[u8], out: &mut [u8]) -> std::result::Result<usize, String> {
208
+ // ── Parse the request frame ─────────────────────────────────
209
+ let mut pos = 0usize;
210
+ let flags = read_u32_at(frame, &mut pos)?;
211
+ let has_body = (flags as u32) & ROUTE_FRAME_FLAG_HAS_BODY != 0;
212
+ let query = read_section(frame, &mut pos, usize::MAX)?;
213
+ let cookie = read_section(frame, &mut pos, usize::MAX)?;
214
+ let body: &[u8] = if has_body {
215
+ read_section(frame, &mut pos, usize::MAX)?
216
+ } else {
217
+ &[]
218
+ };
219
+
220
+ // ── Body verdicts (first-failure-wins, in stage order) ──────
221
+ let mut error_code: u32 = 0;
222
+ let mut body_valid_json = false;
223
+ let mut body_valid = false;
224
+ if self.require_json_body || self.validate_body {
225
+ if !has_body || body.len() > self.max_body_bytes {
226
+ body_valid_json = false;
227
+ if self.require_json_body {
228
+ error_code = ERR_BODY_NOT_JSON;
229
+ }
230
+ } else {
231
+ body_valid_json = crate::json::json_ops::json_valid_bytes(body);
232
+ if self.require_json_body && !body_valid_json {
233
+ error_code = ERR_BODY_NOT_JSON;
234
+ } else if self.validate_body {
235
+ if !body_valid_json {
236
+ // Defensive: the compiler always emits `requireJsonBody`
237
+ // before `validateBody`, so this is an unsupported combo.
238
+ error_code = ERR_BODY_NOT_JSON;
239
+ } else if let Some(schema) = &self.body_schema {
240
+ if schema.validate(body) {
241
+ body_valid = true;
242
+ } else {
243
+ error_code = ERR_BODY_SCHEMA;
244
+ }
245
+ } else {
246
+ // No schema compiled → no validation constraint.
247
+ body_valid = true;
248
+ }
249
+ } else {
250
+ // requireJsonBody passed (a well-formed JSON body is valid).
251
+ body_valid = true;
252
+ }
253
+ }
254
+ }
255
+
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;
265
+ if self.parse_query {
266
+ query_capped = write_query_section(&mut w, &mut scratch, query, self.max_pairs);
267
+ }
268
+ if self.parse_cookies {
269
+ cookie_capped = write_cookie_section(&mut w, cookie, self.max_pairs);
270
+ }
271
+ // validateQuery/validateCookies without a schema are no-ops (the parse
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) ─────────────────
280
+ let mut result_flags: u32 = 0;
281
+ if error_code == 0 {
282
+ result_flags |= ROUTE_RESULT_FLAG_OK;
283
+ }
284
+ if body_valid_json {
285
+ result_flags |= ROUTE_RESULT_FLAG_BODY_VALID_JSON;
286
+ }
287
+ if query_valid {
288
+ result_flags |= ROUTE_RESULT_FLAG_QUERY_VALID;
289
+ }
290
+ if cookie_valid {
291
+ result_flags |= ROUTE_RESULT_FLAG_COOKIE_VALID;
292
+ }
293
+ if body_valid {
294
+ result_flags |= ROUTE_RESULT_FLAG_BODY_VALID;
295
+ }
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))
300
+ }
301
+ }
302
+
303
+ // ── Lenient query parsing (byte-parity with ignex `decodePairList`) ──
304
+
305
+ /// A `name=value` pair (name is the whole segment when there is no `=`).
306
+ struct Pair<'a> {
307
+ name: &'a [u8],
308
+ value: &'a [u8],
309
+ }
310
+
311
+ /// Split `a=1&b=2`-style bytes into `[name, value]` pairs, skipping empty
312
+ /// segments (matches JS `decodePairList`).
313
+ fn query_pairs(query: &[u8]) -> impl Iterator<Item = Pair<'_>> + '_ {
314
+ query
315
+ .split(|&b| b == b'&')
316
+ .filter(|p| !p.is_empty())
317
+ .map(|pair| match pair.iter().position(|&b| b == b'=') {
318
+ Some(eq) => Pair {
319
+ name: &pair[..eq],
320
+ value: &pair[eq + 1..],
321
+ },
322
+ None => Pair {
323
+ name: pair,
324
+ value: &[],
325
+ },
326
+ })
327
+ }
328
+
329
+ // `cookie_pairs` (crate::util::bytes) yields raw (trimmed, DQUOTE-unwrapped,
330
+ // NOT URL-decoded) `name=value` slices — byte-identical to ignex's
331
+ // `cookiePairs` fallback.
332
+
333
+ /// LENIENT segment decode (matches JS `decodeSegment`): `+` → space, `%XX` →
334
+ /// byte, result must be valid UTF-8; on ANY failure the WHOLE original segment
335
+ /// is returned unchanged (with `+` AND `%` intact — the JS catch returns `s`).
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)
352
+ }
353
+
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,
375
+ }
376
+
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;
399
+ }
400
+ }
401
+ }
402
+
403
+ #[inline]
404
+ fn u32(&mut self, value: u32) {
405
+ self.bytes(&value.to_le_bytes());
406
+ }
407
+
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
+ }
414
+
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());
420
+ }
421
+ }
422
+
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;
429
+ }
430
+ self.out[..RESULT_HEADER_LEN].copy_from_slice(&header);
431
+ self.pos
432
+ }
433
+ }
434
+
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
445
+ let mut count = 0usize;
446
+ let mut capped = false;
447
+ for pair in query_pairs(query) {
448
+ if max_pairs > 0 && count >= max_pairs {
449
+ capped = true;
450
+ break;
451
+ }
452
+ w.len_prefixed(decode_segment_scratch(pair.name, scratch));
453
+ w.len_prefixed(decode_segment_scratch(pair.value, scratch));
454
+ count += 1;
455
+ }
456
+ w.patch_u32(count_pos, count as u32);
457
+ capped
458
+ }
459
+
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
465
+ let mut count = 0usize;
466
+ let mut capped = false;
467
+ for (name, value) in cookie_pairs(cookie) {
468
+ if max_pairs > 0 && count >= max_pairs {
469
+ capped = true;
470
+ break;
471
+ }
472
+ w.len_prefixed(name);
473
+ w.len_prefixed(value);
474
+ count += 1;
475
+ }
476
+ w.patch_u32(count_pos, count as u32);
477
+ capped
478
+ }
479
+
480
+ // ── napi boundary: the `Route` class (Node/fallback transport) ─────
481
+ use napi::bindgen_prelude::*;
482
+ use napi::{Error, Status};
483
+ use napi_derive::napi;
484
+
485
+ /// A compiled per-route native stack (napi surface). Compile the descriptor
486
+ /// once at construction; `run` processes one request frame and returns the
487
+ /// packed verdict result bytes written (`0` = error / too-small, `> out.len` =
488
+ /// exact required size — the growExact convention).
489
+ #[napi]
490
+ pub struct Route {
491
+ inner: Arc<NativeRoute>,
492
+ }
493
+
494
+ #[napi]
495
+ impl Route {
496
+ #[napi(constructor)]
497
+ pub fn new(descriptor: Uint8Array) -> Result<Self> {
498
+ let inner = NativeRoute::compile(descriptor.as_ref())
499
+ .map_err(|e| Error::new(Status::InvalidArg, format!("route compile error: {e}")))?;
500
+ Ok(Self {
501
+ inner: Arc::new(inner),
502
+ })
503
+ }
504
+
505
+ /// Run one request frame; returns bytes written (`0` = error / too-small,
506
+ /// `> output.length` = exact required size for a single retry).
507
+ #[napi]
508
+ pub fn run(&self, frame: Uint8Array, mut output: Uint8Array) -> Result<u32> {
509
+ // SAFETY: `output.as_mut()` borrows the caller-provided JS buffer only
510
+ // for this call; the frame is a separate buffer (aliasing is the
511
+ // caller's contract, matching every other napi writer in this crate).
512
+ let out: &mut [u8] = unsafe { output.as_mut() };
513
+ let written = self
514
+ .inner
515
+ .run(frame.as_ref(), out)
516
+ .map_err(|e| Error::from_reason(format!("route run error: {e}")))?;
517
+ Ok(written as u32)
518
+ }
519
+ }
520
+
521
+ #[cfg(test)]
522
+ mod tests {
523
+ use super::*;
524
+
525
+ /// Encode a plan into a descriptor wire (mirror of encodeRouteDescriptor).
526
+ pub(super) fn descriptor(pipeline: &[u8], schemas: &[(u8, &[u8])]) -> Vec<u8> {
527
+ let mut d = Vec::new();
528
+ d.extend_from_slice(&ROUTE_DESC_MAGIC.to_le_bytes());
529
+ d.extend_from_slice(&ROUTE_DESC_VERSION.to_le_bytes());
530
+ d.extend_from_slice(&(2 * 1024 * 1024u32).to_le_bytes()); // maxBodyBytes
531
+ d.extend_from_slice(&8192u32.to_le_bytes()); // maxQueryBytes
532
+ d.extend_from_slice(&8192u32.to_le_bytes()); // maxCookieBytes
533
+ d.extend_from_slice(&0u32.to_le_bytes()); // maxPairs
534
+ d.extend_from_slice(&(pipeline.len() as u32).to_le_bytes());
535
+ d.extend_from_slice(pipeline);
536
+ d.extend_from_slice(&(schemas.len() as u32).to_le_bytes());
537
+ for (part, bytes) in schemas {
538
+ d.push(*part);
539
+ d.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
540
+ d.extend_from_slice(bytes);
541
+ }
542
+ d
543
+ }
544
+
545
+ /// Encode a request frame (mirror of packRouteFrameInto).
546
+ pub(super) fn frame(query: &[u8], cookie: &[u8], body: Option<&[u8]>) -> Vec<u8> {
547
+ let mut f = Vec::new();
548
+ let has_body = body.is_some() && body.map(|b| !b.is_empty()).unwrap_or(false);
549
+ f.extend_from_slice(&(has_body as u32).to_le_bytes());
550
+ f.extend_from_slice(&(query.len() as u32).to_le_bytes());
551
+ f.extend_from_slice(query);
552
+ f.extend_from_slice(&(cookie.len() as u32).to_le_bytes());
553
+ f.extend_from_slice(cookie);
554
+ if has_body {
555
+ let b = body.unwrap_or(&[]);
556
+ f.extend_from_slice(&(b.len() as u32).to_le_bytes());
557
+ f.extend_from_slice(b);
558
+ }
559
+ f
560
+ }
561
+
562
+ fn decode_pairs(wire: &[u8], pos: &mut usize) -> Vec<(Vec<u8>, Vec<u8>)> {
563
+ let count = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
564
+ *pos += 4;
565
+ let mut out = Vec::with_capacity(count);
566
+ for _ in 0..count {
567
+ let nl = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
568
+ *pos += 4;
569
+ let name = wire[*pos..*pos + nl].to_vec();
570
+ *pos += nl;
571
+ let vl = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
572
+ *pos += 4;
573
+ let value = wire[*pos..*pos + vl].to_vec();
574
+ *pos += vl;
575
+ out.push((name, value));
576
+ }
577
+ out
578
+ }
579
+
580
+ #[test]
581
+ fn compile_rejects_bad_magic_and_version() {
582
+ let d = descriptor(&[0], &[]);
583
+ let mut bad = d.clone();
584
+ bad[0] = 0;
585
+ assert!(NativeRoute::compile(&bad).is_err());
586
+
587
+ let mut bad_version = d.clone();
588
+ bad_version[4..8].copy_from_slice(&99u32.to_le_bytes());
589
+ assert!(NativeRoute::compile(&bad_version).is_err());
590
+ }
591
+
592
+ #[test]
593
+ fn compile_rejects_unknown_stage_and_non_body_schema() {
594
+ let d = descriptor(&[99], &[]);
595
+ assert!(NativeRoute::compile(&d).is_err());
596
+ let d = descriptor(&[STAGE_PARSE_QUERY], &[(1, b"{}")]); // part tag 1 = query
597
+ assert!(NativeRoute::compile(&d).is_err());
598
+ }
599
+
600
+ #[test]
601
+ fn parse_query_lenient_matches_js_vectors() {
602
+ // The vectors from scripts/verify-native-route.ts (vs JS queryPairs).
603
+ let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
604
+ let cases: &[(&[u8], &[&[u8]])] = &[
605
+ (b"a=1&b=hello%20world&c=2", &[b"a", b"b", b"c"]),
606
+ (b"m=%ZZ&n=abc%", &[b"m", b"n"]), // malformed → lenient raw
607
+ (b"u=%E2%9C%93", &[b"u"]), // UTF-8 ✓
608
+ (b"p=a+b", &[b"p"]), // + → space
609
+ (b"k=%2B", &[b"k"]), // %2B → literal +
610
+ (b"k&k2=", &[b"k", b"k2"]), // empty value
611
+ (b"q=%FF", &[b"q"]), // invalid UTF-8 → raw
612
+ ];
613
+ for (qs, expected_names) in cases {
614
+ let mut out = vec![0u8; 256];
615
+ let w = r.run(&frame(qs, b"", None), &mut out).unwrap();
616
+ let mut pos = 8;
617
+ let pairs = decode_pairs(&out[..w], &mut pos);
618
+ let names: Vec<Vec<u8>> = pairs.iter().map(|(n, _)| n.clone()).collect();
619
+ assert_eq!(
620
+ names,
621
+ expected_names
622
+ .iter()
623
+ .map(|n| n.to_vec())
624
+ .collect::<Vec<_>>()
625
+ );
626
+ // Spot-check the tricky decodes.
627
+ let vals: Vec<Vec<u8>> = pairs.iter().map(|(_, v)| v.clone()).collect();
628
+ match *qs {
629
+ b"m=%ZZ&n=abc%" => {
630
+ assert_eq!(vals[0], b"%ZZ");
631
+ assert_eq!(vals[1], b"abc%");
632
+ }
633
+ b"u=%E2%9C%93" => assert_eq!(vals[0], "✓".as_bytes()),
634
+ b"p=a+b" => assert_eq!(vals[0], b"a b"),
635
+ b"k=%2B" => assert_eq!(vals[0], b"+"),
636
+ b"q=%FF" => assert_eq!(vals[0], b"%FF"),
637
+ _ => {}
638
+ }
639
+ }
640
+ }
641
+
642
+ #[test]
643
+ fn parse_cookies_matches_js_vectors() {
644
+ let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_COOKIES], &[])).unwrap();
645
+ let cases: &[(&[u8], usize)] = &[
646
+ (b"sid=abc; theme=dark", 2),
647
+ (b"a=1; \"quoted\"=val; spaced = x ", 3),
648
+ (b"empty=; bare", 2),
649
+ ];
650
+ for (cs, expected) in cases {
651
+ let mut out = vec![0u8; 256];
652
+ let w = r.run(&frame(b"", cs, None), &mut out).unwrap();
653
+ let mut pos = 8;
654
+ let pairs = decode_pairs(&out[..w], &mut pos);
655
+ assert_eq!(pairs.len(), *expected);
656
+ // DQUOTE-unwrapped value for the quoted cookie. NOTE: only the VALUE
657
+ // is unquoted (JS `cookiePairsFallback` unquotes `value` but trims
658
+ // `name` as-is — so the NAME keeps its quotes, matching castrum's
659
+ // `cookie_pairs`).
660
+ if cs == b"a=1; \"quoted\"=val; spaced = x " {
661
+ assert_eq!(pairs[1], (b"\"quoted\"".to_vec(), b"val".to_vec()));
662
+ assert_eq!(pairs[2], (b"spaced".to_vec(), b"x".to_vec()));
663
+ }
664
+ }
665
+ }
666
+
667
+ #[test]
668
+ fn mixed_frame_ok_and_flags() {
669
+ let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY, STAGE_PARSE_COOKIES], &[]))
670
+ .unwrap();
671
+ let mut out = vec![0u8; 512];
672
+ let w = r.run(&frame(b"a=1&b=2", b"s=v", None), &mut out).unwrap();
673
+ assert!(w <= 512);
674
+ let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
675
+ assert_ne!(flags & ROUTE_RESULT_FLAG_OK, 0);
676
+ assert_ne!(flags & ROUTE_RESULT_FLAG_QUERY_VALID, 0);
677
+ assert_ne!(flags & ROUTE_RESULT_FLAG_COOKIE_VALID, 0);
678
+ let mut pos = 8;
679
+ let q = decode_pairs(&out[..w], &mut pos);
680
+ let c = decode_pairs(&out[..w], &mut pos);
681
+ assert_eq!(
682
+ q,
683
+ vec![
684
+ (b"a".to_vec(), b"1".to_vec()),
685
+ (b"b".to_vec(), b"2".to_vec())
686
+ ]
687
+ );
688
+ assert_eq!(c, vec![(b"s".to_vec(), b"v".to_vec())]);
689
+ }
690
+
691
+ #[test]
692
+ fn body_validation_verdicts() {
693
+ let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
694
+ let d = descriptor(
695
+ &[STAGE_REQUIRE_JSON_BODY, STAGE_VALIDATE_BODY],
696
+ &[(PART_BODY, schema)],
697
+ );
698
+ let r = NativeRoute::compile(&d).unwrap();
699
+
700
+ let read = |f: Vec<u8>| {
701
+ let mut out = vec![0u8; 512];
702
+ let w = r.run(&f, &mut out).unwrap();
703
+ let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
704
+ let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
705
+ (w, flags, code)
706
+ };
707
+
708
+ // valid body → ok + both flags
709
+ let (_, flags, code) = read(frame(b"", b"", Some(br#"{"x":1}"#)));
710
+ assert_eq!(code, 0);
711
+ assert_ne!(flags & ROUTE_RESULT_FLAG_OK, 0);
712
+ assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
713
+ assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
714
+
715
+ // non-JSON → 400, ok cleared, no valid flags
716
+ let (_, flags, code) = read(frame(b"", b"", Some(b"not json")));
717
+ assert_eq!(code, 400);
718
+ assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
719
+ assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
720
+ assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
721
+
722
+ // JSON but schema-invalid → 422, json valid but body not
723
+ let (_, flags, code) = read(frame(b"", b"", Some(br#"{"x":"str"}"#)));
724
+ assert_eq!(code, 422);
725
+ assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
726
+ assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
727
+ assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
728
+
729
+ // absent body on requireJsonBody → 400
730
+ let (_, flags, code) = read(frame(b"", b"", None));
731
+ assert_eq!(code, 400);
732
+ assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
733
+ }
734
+
735
+ #[test]
736
+ fn body_only_route_is_bare_header() {
737
+ let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
738
+ let r = NativeRoute::compile(&d).unwrap();
739
+ let mut out = vec![0u8; 64];
740
+ let w = r.run(&frame(b"", b"", Some(b"{}")), &mut out).unwrap();
741
+ assert_eq!(w, 8); // header only — no pair sections
742
+ }
743
+
744
+ #[test]
745
+ fn needed_size_convention() {
746
+ let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
747
+ let f = frame(b"a=1&bb=22&ccc=333", b"", None);
748
+ // A tiny buffer reports the exact required size without writing.
749
+ let mut small = [0u8; 8];
750
+ let required = r.run(&f, &mut small).unwrap();
751
+ assert!(required > 8);
752
+ let mut big = vec![0u8; required];
753
+ let w = r.run(&f, &mut big).unwrap();
754
+ assert_eq!(w, required);
755
+ // The first 8 bytes of the small buffer were untouched (nothing written).
756
+ assert_eq!(&small[..], &[0u8; 8]);
757
+ }
758
+
759
+ #[test]
760
+ fn malformed_frame_is_an_error() {
761
+ let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
762
+ // Frame too short to hold the flags word.
763
+ let mut out = [0u8; 64];
764
+ assert!(r.run(&[1, 2, 3], &mut out).is_err());
765
+ // Truncated query section.
766
+ let mut bad = frame(b"a=1", b"", None);
767
+ bad.truncate(bad.len() - 1);
768
+ assert!(r.run(&bad, &mut out).is_err());
769
+ }
770
+ }