castrum 0.9.3 → 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.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,779 @@
|
|
|
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::borrow::Cow;
|
|
30
|
+
use std::sync::Arc;
|
|
31
|
+
|
|
32
|
+
use super::packed::{read_section, read_u32_at};
|
|
33
|
+
use super::IngressSchema;
|
|
34
|
+
use crate::util::bytes::cookie_pairs;
|
|
35
|
+
|
|
36
|
+
// ── Wire constants (MUST match @ignex/native route-wire.ts) ──────
|
|
37
|
+
/// Magic that identifies a route descriptor (`"ROUT"` LE).
|
|
38
|
+
pub(crate) const ROUTE_DESC_MAGIC: u32 = 0x524f5554;
|
|
39
|
+
/// Wire version — bump on ANY layout change (descriptor, frame, or result).
|
|
40
|
+
pub(crate) const ROUTE_DESC_VERSION: u32 = 3;
|
|
41
|
+
|
|
42
|
+
/// Frame flag: the body section is present (bit 0 of the frame flags word).
|
|
43
|
+
pub(crate) const ROUTE_FRAME_FLAG_HAS_BODY: u32 = 1 << 0;
|
|
44
|
+
|
|
45
|
+
/// Result flag: the route stack succeeded (else `errorCode` is meaningful).
|
|
46
|
+
pub(crate) const ROUTE_RESULT_FLAG_OK: u32 = 1 << 0;
|
|
47
|
+
/// Result flag: the body parsed as well-formed JSON.
|
|
48
|
+
pub(crate) const ROUTE_RESULT_FLAG_BODY_VALID_JSON: u32 = 1 << 1;
|
|
49
|
+
/// Result flag: the parsed query satisfied its schema / limits.
|
|
50
|
+
pub(crate) const ROUTE_RESULT_FLAG_QUERY_VALID: u32 = 1 << 2;
|
|
51
|
+
/// Result flag: the parsed cookies satisfied their schema / limits.
|
|
52
|
+
pub(crate) const ROUTE_RESULT_FLAG_COOKIE_VALID: u32 = 1 << 3;
|
|
53
|
+
/// Result flag: the body satisfied its schema (when one exists).
|
|
54
|
+
pub(crate) const ROUTE_RESULT_FLAG_BODY_VALID: u32 = 1 << 4;
|
|
55
|
+
// Wire-contract bits for params/headers validation. This stack never sets them
|
|
56
|
+
// (the compiler only emits parse/body stages), but they MUST stay defined so
|
|
57
|
+
// the result layout matches @ignex/native route-wire.ts.
|
|
58
|
+
#[allow(dead_code)]
|
|
59
|
+
pub(crate) const ROUTE_RESULT_FLAG_PARAMS_VALID: u32 = 1 << 5;
|
|
60
|
+
#[allow(dead_code)]
|
|
61
|
+
pub(crate) const ROUTE_RESULT_FLAG_HEADERS_VALID: u32 = 1 << 6;
|
|
62
|
+
|
|
63
|
+
/// Descriptor stage tags (the ordered pipeline a route instance runs).
|
|
64
|
+
pub(crate) const STAGE_PARSE_QUERY: u8 = 0;
|
|
65
|
+
pub(crate) const STAGE_PARSE_COOKIES: u8 = 1;
|
|
66
|
+
pub(crate) const STAGE_VALIDATE_QUERY: u8 = 2;
|
|
67
|
+
pub(crate) const STAGE_VALIDATE_COOKIES: u8 = 3;
|
|
68
|
+
pub(crate) const STAGE_VALIDATE_BODY: u8 = 4;
|
|
69
|
+
pub(crate) const STAGE_REQUIRE_JSON_BODY: u8 = 5;
|
|
70
|
+
|
|
71
|
+
/// Descriptor part tags (`RoutePartKind`): the schema-bearing request parts.
|
|
72
|
+
const PART_BODY: u8 = 3;
|
|
73
|
+
|
|
74
|
+
/// Body-rejected error codes reported in the result header (0 = ok).
|
|
75
|
+
const ERR_BODY_NOT_JSON: u32 = 400;
|
|
76
|
+
const ERR_BODY_SCHEMA: u32 = 422;
|
|
77
|
+
|
|
78
|
+
/// A compiled, pre-baked per-route native stack.
|
|
79
|
+
pub(crate) struct NativeRoute {
|
|
80
|
+
parse_query: bool,
|
|
81
|
+
parse_cookies: bool,
|
|
82
|
+
require_json_body: bool,
|
|
83
|
+
validate_body: bool,
|
|
84
|
+
max_body_bytes: usize,
|
|
85
|
+
max_query_bytes: usize,
|
|
86
|
+
max_cookie_bytes: usize,
|
|
87
|
+
max_pairs: usize,
|
|
88
|
+
/// Compiled draft-07 body schema (fast_schema + jsonschema dual).
|
|
89
|
+
body_schema: Option<Arc<IngressSchema>>,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
impl NativeRoute {
|
|
93
|
+
/// Compile a route plan from its descriptor wire. Returns a human-readable
|
|
94
|
+
/// error string on a malformed/unsupported descriptor (bad magic/version,
|
|
95
|
+
/// unknown stage/part tag, a query/cookie/params/headers schema — the
|
|
96
|
+
/// current stack validates the BODY only, so a non-body schema is an
|
|
97
|
+
/// unsupported feature → the caller falls back to JS, byte-parity
|
|
98
|
+
/// preserved by design).
|
|
99
|
+
pub(crate) fn compile(desc: &[u8]) -> std::result::Result<Self, String> {
|
|
100
|
+
let mut pos = 0usize;
|
|
101
|
+
let magic = read_u32_at(desc, &mut pos)?;
|
|
102
|
+
if (magic as u32) != ROUTE_DESC_MAGIC {
|
|
103
|
+
return Err(format!("route descriptor: bad magic 0x{magic:08x}"));
|
|
104
|
+
}
|
|
105
|
+
let version = read_u32_at(desc, &mut pos)?;
|
|
106
|
+
if (version as u32) != ROUTE_DESC_VERSION {
|
|
107
|
+
return Err(format!(
|
|
108
|
+
"route descriptor: unsupported version {version} (this build supports {ROUTE_DESC_VERSION})"
|
|
109
|
+
));
|
|
110
|
+
}
|
|
111
|
+
let max_body_bytes = read_u32_at(desc, &mut pos)?;
|
|
112
|
+
let max_query_bytes = read_u32_at(desc, &mut pos)?;
|
|
113
|
+
let max_cookie_bytes = read_u32_at(desc, &mut pos)?;
|
|
114
|
+
let max_pairs = read_u32_at(desc, &mut pos)?;
|
|
115
|
+
|
|
116
|
+
let mut parse_query = false;
|
|
117
|
+
let mut parse_cookies = false;
|
|
118
|
+
let mut require_json_body = false;
|
|
119
|
+
let mut validate_body = false;
|
|
120
|
+
|
|
121
|
+
let stage_count = read_u32_at(desc, &mut pos)?;
|
|
122
|
+
for _ in 0..stage_count {
|
|
123
|
+
let tag = *desc
|
|
124
|
+
.get(pos)
|
|
125
|
+
.ok_or_else(|| "route descriptor: truncated stage list".to_string())?;
|
|
126
|
+
pos += 1;
|
|
127
|
+
match tag {
|
|
128
|
+
STAGE_PARSE_QUERY => parse_query = true,
|
|
129
|
+
STAGE_PARSE_COOKIES => parse_cookies = true,
|
|
130
|
+
// validateQuery/validateCookies are no-ops in this stack (the
|
|
131
|
+
// parse VALID bit is the verdict); a schema for them would have
|
|
132
|
+
// been rejected below.
|
|
133
|
+
STAGE_VALIDATE_QUERY => {}
|
|
134
|
+
STAGE_VALIDATE_COOKIES => {}
|
|
135
|
+
STAGE_VALIDATE_BODY => validate_body = true,
|
|
136
|
+
STAGE_REQUIRE_JSON_BODY => require_json_body = true,
|
|
137
|
+
other => {
|
|
138
|
+
return Err(format!("route descriptor: unknown stage tag {other}"));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let schema_count = read_u32_at(desc, &mut pos)?;
|
|
144
|
+
let mut body_schema_bytes: Option<Vec<u8>> = None;
|
|
145
|
+
for _ in 0..schema_count {
|
|
146
|
+
let part = *desc
|
|
147
|
+
.get(pos)
|
|
148
|
+
.ok_or_else(|| "route descriptor: truncated schema list".to_string())?;
|
|
149
|
+
pos += 1;
|
|
150
|
+
let len = read_u32_at(desc, &mut pos)?;
|
|
151
|
+
let end = pos
|
|
152
|
+
.checked_add(len)
|
|
153
|
+
.ok_or_else(|| "route descriptor: schema length overflow".to_string())?;
|
|
154
|
+
if end > desc.len() {
|
|
155
|
+
return Err("route descriptor: truncated schema".to_string());
|
|
156
|
+
}
|
|
157
|
+
let bytes = &desc[pos..end];
|
|
158
|
+
pos = end;
|
|
159
|
+
match part {
|
|
160
|
+
PART_BODY => body_schema_bytes = Some(bytes.to_vec()),
|
|
161
|
+
other => {
|
|
162
|
+
// This stack validates the BODY only. A schema for any other
|
|
163
|
+
// part (params/query/cookie/headers/response) is unsupported
|
|
164
|
+
// → fail compilation so the caller falls back to JS
|
|
165
|
+
// (byte-parity preserved by design). validateQuery /
|
|
166
|
+
// validateCookies WITHOUT a schema are a no-op (the parse
|
|
167
|
+
// VALID bit is the verdict), so they never reach this check.
|
|
168
|
+
return Err(format!(
|
|
169
|
+
"route descriptor: unsupported schema part tag {other} (this stack validates the body only)"
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
let body_schema = match body_schema_bytes {
|
|
176
|
+
Some(bytes) => {
|
|
177
|
+
let schema_str = std::str::from_utf8(&bytes)
|
|
178
|
+
.map_err(|_| "route descriptor: body schema is not valid UTF-8".to_string())?;
|
|
179
|
+
let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
|
|
180
|
+
.map_err(|e| format!("route descriptor: body schema JSON error: {e}"))?;
|
|
181
|
+
let compiled = IngressSchema::compile(&schema_value)
|
|
182
|
+
.map_err(|e| format!("route descriptor: body schema compile error: {e}"))?;
|
|
183
|
+
Some(Arc::new(compiled))
|
|
184
|
+
}
|
|
185
|
+
None => None,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
Ok(Self {
|
|
189
|
+
parse_query,
|
|
190
|
+
parse_cookies,
|
|
191
|
+
require_json_body,
|
|
192
|
+
validate_body,
|
|
193
|
+
max_body_bytes,
|
|
194
|
+
max_query_bytes,
|
|
195
|
+
max_cookie_bytes,
|
|
196
|
+
max_pairs,
|
|
197
|
+
body_schema,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Run the pre-baked stack for one request frame, writing the packed
|
|
202
|
+
/// result into `out`. Returns `Ok(n)` where `n` is the number of bytes
|
|
203
|
+
/// written when `out` is large enough, OR the exact required size when it
|
|
204
|
+
/// is too small (the needed-size convention: `0` = real error, `> out.len`
|
|
205
|
+
/// = exact required). `Err` = malformed frame (→ 0 on the C ABI).
|
|
206
|
+
///
|
|
207
|
+
/// Pure: `&self`, no interior mutability — safe for concurrent use.
|
|
208
|
+
pub(crate) fn run(&self, frame: &[u8], out: &mut [u8]) -> std::result::Result<usize, String> {
|
|
209
|
+
// ── Parse the request frame ─────────────────────────────────
|
|
210
|
+
let mut pos = 0usize;
|
|
211
|
+
let flags = read_u32_at(frame, &mut pos)?;
|
|
212
|
+
let has_body = (flags as u32) & ROUTE_FRAME_FLAG_HAS_BODY != 0;
|
|
213
|
+
let query = read_section(frame, &mut pos, usize::MAX)?;
|
|
214
|
+
let cookie = read_section(frame, &mut pos, usize::MAX)?;
|
|
215
|
+
let body: &[u8] = if has_body {
|
|
216
|
+
read_section(frame, &mut pos, usize::MAX)?
|
|
217
|
+
} else {
|
|
218
|
+
&[]
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
// ── Body verdicts (first-failure-wins, in stage order) ──────
|
|
222
|
+
let mut error_code: u32 = 0;
|
|
223
|
+
let mut body_valid_json = false;
|
|
224
|
+
let mut body_valid = false;
|
|
225
|
+
if self.require_json_body || self.validate_body {
|
|
226
|
+
if !has_body || body.len() > self.max_body_bytes {
|
|
227
|
+
body_valid_json = false;
|
|
228
|
+
if self.require_json_body {
|
|
229
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
230
|
+
}
|
|
231
|
+
} else {
|
|
232
|
+
body_valid_json = crate::json::json_ops::json_valid_bytes(body);
|
|
233
|
+
if self.require_json_body && !body_valid_json {
|
|
234
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
235
|
+
} else if self.validate_body {
|
|
236
|
+
if !body_valid_json {
|
|
237
|
+
// Defensive: the compiler always emits `requireJsonBody`
|
|
238
|
+
// before `validateBody`, so this is an unsupported combo.
|
|
239
|
+
error_code = ERR_BODY_NOT_JSON;
|
|
240
|
+
} else if let Some(schema) = &self.body_schema {
|
|
241
|
+
if schema.validate(body) {
|
|
242
|
+
body_valid = true;
|
|
243
|
+
} else {
|
|
244
|
+
error_code = ERR_BODY_SCHEMA;
|
|
245
|
+
}
|
|
246
|
+
} else {
|
|
247
|
+
// No schema compiled → no validation constraint.
|
|
248
|
+
body_valid = true;
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
// requireJsonBody passed (a well-formed JSON body is valid).
|
|
252
|
+
body_valid = true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ── Parse + size the pair sections ──────────────────────────
|
|
258
|
+
let mut query_size = 0usize;
|
|
259
|
+
let mut cookie_size = 0usize;
|
|
260
|
+
let mut query_valid = false;
|
|
261
|
+
let mut cookie_valid = false;
|
|
262
|
+
if self.parse_query {
|
|
263
|
+
let over = query.len() > self.max_query_bytes;
|
|
264
|
+
let (size, capped) = query_section_size(query, self.max_pairs);
|
|
265
|
+
query_size = size;
|
|
266
|
+
query_valid = !over && !capped;
|
|
267
|
+
}
|
|
268
|
+
if self.parse_cookies {
|
|
269
|
+
let over = cookie.len() > self.max_cookie_bytes;
|
|
270
|
+
let (size, capped) = cookie_section_size(cookie, self.max_pairs);
|
|
271
|
+
cookie_size = size;
|
|
272
|
+
cookie_valid = !over && !capped;
|
|
273
|
+
}
|
|
274
|
+
// validateQuery/validateCookies without a schema are no-ops (the parse
|
|
275
|
+
// VALID bit above is the verdict); with a schema, compile would have
|
|
276
|
+
// rejected the descriptor → the caller fell back to JS.
|
|
277
|
+
|
|
278
|
+
// ── Assemble the result ─────────────────────────────────────
|
|
279
|
+
let mut result_flags: u32 = 0;
|
|
280
|
+
if error_code == 0 {
|
|
281
|
+
result_flags |= ROUTE_RESULT_FLAG_OK;
|
|
282
|
+
}
|
|
283
|
+
if body_valid_json {
|
|
284
|
+
result_flags |= ROUTE_RESULT_FLAG_BODY_VALID_JSON;
|
|
285
|
+
}
|
|
286
|
+
if query_valid {
|
|
287
|
+
result_flags |= ROUTE_RESULT_FLAG_QUERY_VALID;
|
|
288
|
+
}
|
|
289
|
+
if cookie_valid {
|
|
290
|
+
result_flags |= ROUTE_RESULT_FLAG_COOKIE_VALID;
|
|
291
|
+
}
|
|
292
|
+
if body_valid {
|
|
293
|
+
result_flags |= ROUTE_RESULT_FLAG_BODY_VALID;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
let required = 8 + query_size + cookie_size;
|
|
297
|
+
if out.len() < required {
|
|
298
|
+
return Ok(required); // needed-size: report the exact size, write nothing
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let mut w = 0usize;
|
|
302
|
+
// NOTE: pass the FULL `out` slice (not `&mut out[w..]`) — `w` is the
|
|
303
|
+
// absolute write position, so re-slicing would double-apply the offset
|
|
304
|
+
// and scatter the header across the buffer.
|
|
305
|
+
write_u32(out, &mut w, result_flags);
|
|
306
|
+
write_u32(out, &mut w, error_code);
|
|
307
|
+
if self.parse_query {
|
|
308
|
+
write_query_section(out, &mut w, query, self.max_pairs);
|
|
309
|
+
}
|
|
310
|
+
if self.parse_cookies {
|
|
311
|
+
write_cookie_section(out, &mut w, cookie, self.max_pairs);
|
|
312
|
+
}
|
|
313
|
+
Ok(w)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ── Lenient query parsing (byte-parity with ignex `decodePairList`) ──
|
|
318
|
+
|
|
319
|
+
/// A `name=value` pair (name is the whole segment when there is no `=`).
|
|
320
|
+
struct Pair<'a> {
|
|
321
|
+
name: &'a [u8],
|
|
322
|
+
value: &'a [u8],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// Split `a=1&b=2`-style bytes into `[name, value]` pairs, skipping empty
|
|
326
|
+
/// segments (matches JS `decodePairList`).
|
|
327
|
+
fn query_pairs(query: &[u8]) -> impl Iterator<Item = Pair<'_>> + '_ {
|
|
328
|
+
query
|
|
329
|
+
.split(|&b| b == b'&')
|
|
330
|
+
.filter(|p| !p.is_empty())
|
|
331
|
+
.map(|pair| match pair.iter().position(|&b| b == b'=') {
|
|
332
|
+
Some(eq) => Pair {
|
|
333
|
+
name: &pair[..eq],
|
|
334
|
+
value: &pair[eq + 1..],
|
|
335
|
+
},
|
|
336
|
+
None => Pair {
|
|
337
|
+
name: pair,
|
|
338
|
+
value: &[],
|
|
339
|
+
},
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// `cookie_pairs` (crate::util::bytes) yields raw (trimmed, DQUOTE-unwrapped,
|
|
344
|
+
// NOT URL-decoded) `name=value` slices — byte-identical to ignex's
|
|
345
|
+
// `cookiePairs` fallback.
|
|
346
|
+
|
|
347
|
+
/// LENIENT segment decode (matches JS `decodeSegment`): `+` → space, `%XX` →
|
|
348
|
+
/// byte, result must be valid UTF-8; on ANY failure the WHOLE original segment
|
|
349
|
+
/// is returned unchanged (with `+` AND `%` intact — the JS catch returns `s`).
|
|
350
|
+
fn decode_segment_lenient(seg: &[u8]) -> Cow<'_, [u8]> {
|
|
351
|
+
if memchr::memchr2(b'+', b'%', seg).is_none() {
|
|
352
|
+
return Cow::Borrowed(seg);
|
|
353
|
+
}
|
|
354
|
+
let mut replaced = Vec::with_capacity(seg.len());
|
|
355
|
+
for &b in seg {
|
|
356
|
+
replaced.push(if b == b'+' { b' ' } else { b });
|
|
357
|
+
}
|
|
358
|
+
match decode_percent_utf8(&replaced) {
|
|
359
|
+
Some(decoded) => Cow::Owned(decoded),
|
|
360
|
+
None => Cow::Borrowed(seg),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/// The decoded byte length of `seg` under the lenient rules (the original
|
|
365
|
+
/// length when decoding fails — the segment is passed through raw).
|
|
366
|
+
#[inline]
|
|
367
|
+
fn decode_segment_len(seg: &[u8]) -> usize {
|
|
368
|
+
decode_segment_lenient(seg).len()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/// Percent-decode `input` (which has already had `+`→space applied) and
|
|
372
|
+
/// validate the result is well-formed UTF-8. Returns `None` on a malformed
|
|
373
|
+
/// `%XX` OR invalid UTF-8 (both throw in `decodeURIComponent`).
|
|
374
|
+
fn decode_percent_utf8(input: &[u8]) -> Option<Vec<u8>> {
|
|
375
|
+
let mut out = Vec::with_capacity(input.len());
|
|
376
|
+
let mut i = 0usize;
|
|
377
|
+
while i < input.len() {
|
|
378
|
+
match input[i] {
|
|
379
|
+
b'%' => {
|
|
380
|
+
if i + 2 >= input.len() {
|
|
381
|
+
return None;
|
|
382
|
+
}
|
|
383
|
+
let hi = hex_val(input[i + 1])?;
|
|
384
|
+
let lo = hex_val(input[i + 2])?;
|
|
385
|
+
out.push((hi << 4) | lo);
|
|
386
|
+
i += 3;
|
|
387
|
+
}
|
|
388
|
+
b => {
|
|
389
|
+
out.push(b);
|
|
390
|
+
i += 1;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
std::str::from_utf8(&out).ok()?;
|
|
395
|
+
Some(out)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/// Hex digit → nibble (None for non-hex).
|
|
399
|
+
#[inline]
|
|
400
|
+
fn hex_val(b: u8) -> Option<u8> {
|
|
401
|
+
match b {
|
|
402
|
+
b'0'..=b'9' => Some(b - b'0'),
|
|
403
|
+
b'a'..=b'f' => Some(b - b'a' + 10),
|
|
404
|
+
b'A'..=b'F' => Some(b - b'A' + 10),
|
|
405
|
+
_ => None,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── Pair-section size + write (two passes, zero alloc) ────────────
|
|
410
|
+
|
|
411
|
+
/// Size pass over decoded query pairs (name/value both lenient-decoded).
|
|
412
|
+
fn query_section_size(query: &[u8], max_pairs: usize) -> (usize, bool) {
|
|
413
|
+
let mut size = 4usize;
|
|
414
|
+
let mut capped = false;
|
|
415
|
+
for (count, pair) in query_pairs(query).enumerate() {
|
|
416
|
+
if max_pairs > 0 && count >= max_pairs {
|
|
417
|
+
capped = true;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
size += 4 + decode_segment_len(pair.name) + 4 + decode_segment_len(pair.value);
|
|
421
|
+
}
|
|
422
|
+
(size, capped)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// Size pass over raw cookie pairs (no URL-decoding).
|
|
426
|
+
fn cookie_section_size(cookie: &[u8], max_pairs: usize) -> (usize, bool) {
|
|
427
|
+
let mut size = 4usize;
|
|
428
|
+
let mut capped = false;
|
|
429
|
+
for (count, (name, value)) in cookie_pairs(cookie).enumerate() {
|
|
430
|
+
if max_pairs > 0 && count >= max_pairs {
|
|
431
|
+
capped = true;
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
size += 4 + name.len() + 4 + value.len();
|
|
435
|
+
}
|
|
436
|
+
(size, capped)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/// Write a decoded segment as `[u32 len][bytes]` into `out` at `pos`.
|
|
440
|
+
#[inline]
|
|
441
|
+
fn write_segment(out: &mut [u8], pos: &mut usize, seg: &[u8]) {
|
|
442
|
+
let len = seg.len();
|
|
443
|
+
out[*pos..*pos + 4].copy_from_slice(&(len as u32).to_le_bytes());
|
|
444
|
+
*pos += 4;
|
|
445
|
+
out[*pos..*pos + len].copy_from_slice(seg);
|
|
446
|
+
*pos += len;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/// Write the query pair section (lenient-decoded names/values).
|
|
450
|
+
fn write_query_section(out: &mut [u8], pos: &mut usize, query: &[u8], max_pairs: usize) {
|
|
451
|
+
let count_pos = *pos;
|
|
452
|
+
write_u32(out, pos, 0); // count placeholder
|
|
453
|
+
let mut count = 0usize;
|
|
454
|
+
for pair in query_pairs(query) {
|
|
455
|
+
if max_pairs > 0 && count >= max_pairs {
|
|
456
|
+
break;
|
|
457
|
+
}
|
|
458
|
+
write_segment(out, pos, &decode_segment_lenient(pair.name));
|
|
459
|
+
write_segment(out, pos, &decode_segment_lenient(pair.value));
|
|
460
|
+
count += 1;
|
|
461
|
+
}
|
|
462
|
+
out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Write the cookie pair section (raw trimmed/unquoted names/values).
|
|
466
|
+
fn write_cookie_section(out: &mut [u8], pos: &mut usize, cookie: &[u8], max_pairs: usize) {
|
|
467
|
+
let count_pos = *pos;
|
|
468
|
+
write_u32(out, pos, 0); // count placeholder
|
|
469
|
+
let mut count = 0usize;
|
|
470
|
+
for (name, value) in cookie_pairs(cookie) {
|
|
471
|
+
if max_pairs > 0 && count >= max_pairs {
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
write_segment(out, pos, name);
|
|
475
|
+
write_segment(out, pos, value);
|
|
476
|
+
count += 1;
|
|
477
|
+
}
|
|
478
|
+
out[count_pos..count_pos + 4].copy_from_slice(&(count as u32).to_le_bytes());
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/// Write a u32 LE into `out` at `pos`, advancing `pos`. Caller guarantees
|
|
482
|
+
/// capacity (sizes are pre-computed).
|
|
483
|
+
#[inline]
|
|
484
|
+
fn write_u32(out: &mut [u8], pos: &mut usize, value: u32) {
|
|
485
|
+
out[*pos..*pos + 4].copy_from_slice(&value.to_le_bytes());
|
|
486
|
+
*pos += 4;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── napi boundary: the `Route` class (Node/fallback transport) ─────
|
|
490
|
+
use napi::bindgen_prelude::*;
|
|
491
|
+
use napi::{Error, Status};
|
|
492
|
+
use napi_derive::napi;
|
|
493
|
+
|
|
494
|
+
/// A compiled per-route native stack (napi surface). Compile the descriptor
|
|
495
|
+
/// once at construction; `run` processes one request frame and returns the
|
|
496
|
+
/// packed verdict result bytes written (`0` = error / too-small, `> out.len` =
|
|
497
|
+
/// exact required size — the growExact convention).
|
|
498
|
+
#[napi]
|
|
499
|
+
pub struct Route {
|
|
500
|
+
inner: Arc<NativeRoute>,
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#[napi]
|
|
504
|
+
impl Route {
|
|
505
|
+
#[napi(constructor)]
|
|
506
|
+
pub fn new(descriptor: Uint8Array) -> Result<Self> {
|
|
507
|
+
let inner = NativeRoute::compile(descriptor.as_ref())
|
|
508
|
+
.map_err(|e| Error::new(Status::InvalidArg, format!("route compile error: {e}")))?;
|
|
509
|
+
Ok(Self {
|
|
510
|
+
inner: Arc::new(inner),
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/// Run one request frame; returns bytes written (`0` = error / too-small,
|
|
515
|
+
/// `> output.length` = exact required size for a single retry).
|
|
516
|
+
#[napi]
|
|
517
|
+
pub fn run(&self, frame: Uint8Array, mut output: Uint8Array) -> Result<u32> {
|
|
518
|
+
// SAFETY: `output.as_mut()` borrows the caller-provided JS buffer only
|
|
519
|
+
// for this call; the frame is a separate buffer (aliasing is the
|
|
520
|
+
// caller's contract, matching every other napi writer in this crate).
|
|
521
|
+
let out: &mut [u8] = unsafe { output.as_mut() };
|
|
522
|
+
let written = self
|
|
523
|
+
.inner
|
|
524
|
+
.run(frame.as_ref(), out)
|
|
525
|
+
.map_err(|e| Error::from_reason(format!("route run error: {e}")))?;
|
|
526
|
+
Ok(written as u32)
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[cfg(test)]
|
|
531
|
+
mod tests {
|
|
532
|
+
use super::*;
|
|
533
|
+
|
|
534
|
+
/// Encode a plan into a descriptor wire (mirror of encodeRouteDescriptor).
|
|
535
|
+
pub(super) fn descriptor(pipeline: &[u8], schemas: &[(u8, &[u8])]) -> Vec<u8> {
|
|
536
|
+
let mut d = Vec::new();
|
|
537
|
+
d.extend_from_slice(&ROUTE_DESC_MAGIC.to_le_bytes());
|
|
538
|
+
d.extend_from_slice(&ROUTE_DESC_VERSION.to_le_bytes());
|
|
539
|
+
d.extend_from_slice(&(2 * 1024 * 1024u32).to_le_bytes()); // maxBodyBytes
|
|
540
|
+
d.extend_from_slice(&8192u32.to_le_bytes()); // maxQueryBytes
|
|
541
|
+
d.extend_from_slice(&8192u32.to_le_bytes()); // maxCookieBytes
|
|
542
|
+
d.extend_from_slice(&0u32.to_le_bytes()); // maxPairs
|
|
543
|
+
d.extend_from_slice(&(pipeline.len() as u32).to_le_bytes());
|
|
544
|
+
d.extend_from_slice(pipeline);
|
|
545
|
+
d.extend_from_slice(&(schemas.len() as u32).to_le_bytes());
|
|
546
|
+
for (part, bytes) in schemas {
|
|
547
|
+
d.push(*part);
|
|
548
|
+
d.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
|
|
549
|
+
d.extend_from_slice(bytes);
|
|
550
|
+
}
|
|
551
|
+
d
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/// Encode a request frame (mirror of packRouteFrameInto).
|
|
555
|
+
pub(super) fn frame(query: &[u8], cookie: &[u8], body: Option<&[u8]>) -> Vec<u8> {
|
|
556
|
+
let mut f = Vec::new();
|
|
557
|
+
let has_body = body.is_some() && body.map(|b| !b.is_empty()).unwrap_or(false);
|
|
558
|
+
f.extend_from_slice(&(has_body as u32).to_le_bytes());
|
|
559
|
+
f.extend_from_slice(&(query.len() as u32).to_le_bytes());
|
|
560
|
+
f.extend_from_slice(query);
|
|
561
|
+
f.extend_from_slice(&(cookie.len() as u32).to_le_bytes());
|
|
562
|
+
f.extend_from_slice(cookie);
|
|
563
|
+
if has_body {
|
|
564
|
+
let b = body.unwrap_or(&[]);
|
|
565
|
+
f.extend_from_slice(&(b.len() as u32).to_le_bytes());
|
|
566
|
+
f.extend_from_slice(b);
|
|
567
|
+
}
|
|
568
|
+
f
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
fn decode_pairs(wire: &[u8], pos: &mut usize) -> Vec<(Vec<u8>, Vec<u8>)> {
|
|
572
|
+
let count = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
|
|
573
|
+
*pos += 4;
|
|
574
|
+
let mut out = Vec::with_capacity(count);
|
|
575
|
+
for _ in 0..count {
|
|
576
|
+
let nl = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
|
|
577
|
+
*pos += 4;
|
|
578
|
+
let name = wire[*pos..*pos + nl].to_vec();
|
|
579
|
+
*pos += nl;
|
|
580
|
+
let vl = u32::from_le_bytes(wire[*pos..*pos + 4].try_into().unwrap()) as usize;
|
|
581
|
+
*pos += 4;
|
|
582
|
+
let value = wire[*pos..*pos + vl].to_vec();
|
|
583
|
+
*pos += vl;
|
|
584
|
+
out.push((name, value));
|
|
585
|
+
}
|
|
586
|
+
out
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
#[test]
|
|
590
|
+
fn compile_rejects_bad_magic_and_version() {
|
|
591
|
+
let d = descriptor(&[0], &[]);
|
|
592
|
+
let mut bad = d.clone();
|
|
593
|
+
bad[0] = 0;
|
|
594
|
+
assert!(NativeRoute::compile(&bad).is_err());
|
|
595
|
+
|
|
596
|
+
let mut bad_version = d.clone();
|
|
597
|
+
bad_version[4..8].copy_from_slice(&99u32.to_le_bytes());
|
|
598
|
+
assert!(NativeRoute::compile(&bad_version).is_err());
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#[test]
|
|
602
|
+
fn compile_rejects_unknown_stage_and_non_body_schema() {
|
|
603
|
+
let d = descriptor(&[99], &[]);
|
|
604
|
+
assert!(NativeRoute::compile(&d).is_err());
|
|
605
|
+
let d = descriptor(&[STAGE_PARSE_QUERY], &[(1, b"{}")]); // part tag 1 = query
|
|
606
|
+
assert!(NativeRoute::compile(&d).is_err());
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
#[test]
|
|
610
|
+
fn parse_query_lenient_matches_js_vectors() {
|
|
611
|
+
// The vectors from scripts/verify-native-route.ts (vs JS queryPairs).
|
|
612
|
+
let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
|
|
613
|
+
let cases: &[(&[u8], &[&[u8]])] = &[
|
|
614
|
+
(b"a=1&b=hello%20world&c=2", &[b"a", b"b", b"c"]),
|
|
615
|
+
(b"m=%ZZ&n=abc%", &[b"m", b"n"]), // malformed → lenient raw
|
|
616
|
+
(b"u=%E2%9C%93", &[b"u"]), // UTF-8 ✓
|
|
617
|
+
(b"p=a+b", &[b"p"]), // + → space
|
|
618
|
+
(b"k=%2B", &[b"k"]), // %2B → literal +
|
|
619
|
+
(b"k&k2=", &[b"k", b"k2"]), // empty value
|
|
620
|
+
(b"q=%FF", &[b"q"]), // invalid UTF-8 → raw
|
|
621
|
+
];
|
|
622
|
+
for (qs, expected_names) in cases {
|
|
623
|
+
let mut out = vec![0u8; 256];
|
|
624
|
+
let w = r.run(&frame(qs, b"", None), &mut out).unwrap();
|
|
625
|
+
let mut pos = 8;
|
|
626
|
+
let pairs = decode_pairs(&out[..w], &mut pos);
|
|
627
|
+
let names: Vec<Vec<u8>> = pairs.iter().map(|(n, _)| n.clone()).collect();
|
|
628
|
+
assert_eq!(
|
|
629
|
+
names,
|
|
630
|
+
expected_names
|
|
631
|
+
.iter()
|
|
632
|
+
.map(|n| n.to_vec())
|
|
633
|
+
.collect::<Vec<_>>()
|
|
634
|
+
);
|
|
635
|
+
// Spot-check the tricky decodes.
|
|
636
|
+
let vals: Vec<Vec<u8>> = pairs.iter().map(|(_, v)| v.clone()).collect();
|
|
637
|
+
match *qs {
|
|
638
|
+
b"m=%ZZ&n=abc%" => {
|
|
639
|
+
assert_eq!(vals[0], b"%ZZ");
|
|
640
|
+
assert_eq!(vals[1], b"abc%");
|
|
641
|
+
}
|
|
642
|
+
b"u=%E2%9C%93" => assert_eq!(vals[0], "✓".as_bytes()),
|
|
643
|
+
b"p=a+b" => assert_eq!(vals[0], b"a b"),
|
|
644
|
+
b"k=%2B" => assert_eq!(vals[0], b"+"),
|
|
645
|
+
b"q=%FF" => assert_eq!(vals[0], b"%FF"),
|
|
646
|
+
_ => {}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
#[test]
|
|
652
|
+
fn parse_cookies_matches_js_vectors() {
|
|
653
|
+
let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_COOKIES], &[])).unwrap();
|
|
654
|
+
let cases: &[(&[u8], usize)] = &[
|
|
655
|
+
(b"sid=abc; theme=dark", 2),
|
|
656
|
+
(b"a=1; \"quoted\"=val; spaced = x ", 3),
|
|
657
|
+
(b"empty=; bare", 2),
|
|
658
|
+
];
|
|
659
|
+
for (cs, expected) in cases {
|
|
660
|
+
let mut out = vec![0u8; 256];
|
|
661
|
+
let w = r.run(&frame(b"", cs, None), &mut out).unwrap();
|
|
662
|
+
let mut pos = 8;
|
|
663
|
+
let pairs = decode_pairs(&out[..w], &mut pos);
|
|
664
|
+
assert_eq!(pairs.len(), *expected);
|
|
665
|
+
// DQUOTE-unwrapped value for the quoted cookie. NOTE: only the VALUE
|
|
666
|
+
// is unquoted (JS `cookiePairsFallback` unquotes `value` but trims
|
|
667
|
+
// `name` as-is — so the NAME keeps its quotes, matching castrum's
|
|
668
|
+
// `cookie_pairs`).
|
|
669
|
+
if cs == b"a=1; \"quoted\"=val; spaced = x " {
|
|
670
|
+
assert_eq!(pairs[1], (b"\"quoted\"".to_vec(), b"val".to_vec()));
|
|
671
|
+
assert_eq!(pairs[2], (b"spaced".to_vec(), b"x".to_vec()));
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
#[test]
|
|
677
|
+
fn mixed_frame_ok_and_flags() {
|
|
678
|
+
let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY, STAGE_PARSE_COOKIES], &[]))
|
|
679
|
+
.unwrap();
|
|
680
|
+
let mut out = vec![0u8; 512];
|
|
681
|
+
let w = r.run(&frame(b"a=1&b=2", b"s=v", None), &mut out).unwrap();
|
|
682
|
+
assert!(w <= 512);
|
|
683
|
+
let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
|
|
684
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
685
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_QUERY_VALID, 0);
|
|
686
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_COOKIE_VALID, 0);
|
|
687
|
+
let mut pos = 8;
|
|
688
|
+
let q = decode_pairs(&out[..w], &mut pos);
|
|
689
|
+
let c = decode_pairs(&out[..w], &mut pos);
|
|
690
|
+
assert_eq!(
|
|
691
|
+
q,
|
|
692
|
+
vec![
|
|
693
|
+
(b"a".to_vec(), b"1".to_vec()),
|
|
694
|
+
(b"b".to_vec(), b"2".to_vec())
|
|
695
|
+
]
|
|
696
|
+
);
|
|
697
|
+
assert_eq!(c, vec![(b"s".to_vec(), b"v".to_vec())]);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
#[test]
|
|
701
|
+
fn body_validation_verdicts() {
|
|
702
|
+
let schema = br#"{"type":"object","required":["x"],"properties":{"x":{"type":"number"}}}"#;
|
|
703
|
+
let d = descriptor(
|
|
704
|
+
&[STAGE_REQUIRE_JSON_BODY, STAGE_VALIDATE_BODY],
|
|
705
|
+
&[(PART_BODY, schema)],
|
|
706
|
+
);
|
|
707
|
+
let r = NativeRoute::compile(&d).unwrap();
|
|
708
|
+
|
|
709
|
+
let read = |f: Vec<u8>| {
|
|
710
|
+
let mut out = vec![0u8; 512];
|
|
711
|
+
let w = r.run(&f, &mut out).unwrap();
|
|
712
|
+
let flags = u32::from_le_bytes(out[0..4].try_into().unwrap());
|
|
713
|
+
let code = u32::from_le_bytes(out[4..8].try_into().unwrap());
|
|
714
|
+
(w, flags, code)
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
// valid body → ok + both flags
|
|
718
|
+
let (_, flags, code) = read(frame(b"", b"", Some(br#"{"x":1}"#)));
|
|
719
|
+
assert_eq!(code, 0);
|
|
720
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
721
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
|
|
722
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
|
|
723
|
+
|
|
724
|
+
// non-JSON → 400, ok cleared, no valid flags
|
|
725
|
+
let (_, flags, code) = read(frame(b"", b"", Some(b"not json")));
|
|
726
|
+
assert_eq!(code, 400);
|
|
727
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
728
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
|
|
729
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
|
|
730
|
+
|
|
731
|
+
// JSON but schema-invalid → 422, json valid but body not
|
|
732
|
+
let (_, flags, code) = read(frame(b"", b"", Some(br#"{"x":"str"}"#)));
|
|
733
|
+
assert_eq!(code, 422);
|
|
734
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
735
|
+
assert_ne!(flags & ROUTE_RESULT_FLAG_BODY_VALID_JSON, 0);
|
|
736
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_BODY_VALID, 0);
|
|
737
|
+
|
|
738
|
+
// absent body on requireJsonBody → 400
|
|
739
|
+
let (_, flags, code) = read(frame(b"", b"", None));
|
|
740
|
+
assert_eq!(code, 400);
|
|
741
|
+
assert_eq!(flags & ROUTE_RESULT_FLAG_OK, 0);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
#[test]
|
|
745
|
+
fn body_only_route_is_bare_header() {
|
|
746
|
+
let d = descriptor(&[STAGE_REQUIRE_JSON_BODY], &[]);
|
|
747
|
+
let r = NativeRoute::compile(&d).unwrap();
|
|
748
|
+
let mut out = vec![0u8; 64];
|
|
749
|
+
let w = r.run(&frame(b"", b"", Some(b"{}")), &mut out).unwrap();
|
|
750
|
+
assert_eq!(w, 8); // header only — no pair sections
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
#[test]
|
|
754
|
+
fn needed_size_convention() {
|
|
755
|
+
let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
|
|
756
|
+
let f = frame(b"a=1&bb=22&ccc=333", b"", None);
|
|
757
|
+
// A tiny buffer reports the exact required size without writing.
|
|
758
|
+
let mut small = [0u8; 8];
|
|
759
|
+
let required = r.run(&f, &mut small).unwrap();
|
|
760
|
+
assert!(required > 8);
|
|
761
|
+
let mut big = vec![0u8; required];
|
|
762
|
+
let w = r.run(&f, &mut big).unwrap();
|
|
763
|
+
assert_eq!(w, required);
|
|
764
|
+
// The first 8 bytes of the small buffer were untouched (nothing written).
|
|
765
|
+
assert_eq!(&small[..], &[0u8; 8]);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
#[test]
|
|
769
|
+
fn malformed_frame_is_an_error() {
|
|
770
|
+
let r = NativeRoute::compile(&descriptor(&[STAGE_PARSE_QUERY], &[])).unwrap();
|
|
771
|
+
// Frame too short to hold the flags word.
|
|
772
|
+
let mut out = [0u8; 64];
|
|
773
|
+
assert!(r.run(&[1, 2, 3], &mut out).is_err());
|
|
774
|
+
// Truncated query section.
|
|
775
|
+
let mut bad = frame(b"a=1", b"", None);
|
|
776
|
+
bad.truncate(bad.len() - 1);
|
|
777
|
+
assert!(r.run(&bad, &mut out).is_err());
|
|
778
|
+
}
|
|
779
|
+
}
|