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.
- package/Cargo.lock +2242 -0
- package/Cargo.toml +155 -0
- package/README.md +14 -6
- package/build.rs +5 -0
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +1508 -1291
- package/dist/index.js +3391 -2069
- package/docs/API.md +2 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +5 -3
- package/docs/ENVIRONMENT.md +25 -2
- package/docs/FFI_BUN_GUIDE.md +242 -14
- package/docs/HOW_TO_ADD_AN_OP.md +10 -0
- package/docs/REPO_MAP.md +17 -6
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +24 -25
- package/package.json +173 -156
- 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 +105 -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 +69 -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/task.rs +172 -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 +136 -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 +211 -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 +770 -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 +567 -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 +883 -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 +73 -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/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -0
- package/rust/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 +594 -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/health.ts +1 -2
- 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 +130 -12
- package/src/native/ffi/build/compress.ts +22 -10
- package/src/native/ffi/build/instances.ts +46 -4
- package/src/native/ffi/build/metrics.ts +247 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +32 -1
- package/src/native/ffi/build.ts +4 -0
- package/src/native/ffi/selftest.ts +12 -3
- package/src/native/ffi/types.ts +151 -9
- package/src/native/ffi.ts +160 -15
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +55 -5
- 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 +71 -27
- 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/rust-ffi/text.ts +25 -3
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +24 -4
- package/src/shared/codec.ts +18 -3
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- 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
|
+
}
|