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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,534 @@
1
+ // rust/ingress/pipeline.rs — The core ingress request pipeline.
2
+ //
3
+ // `IngressInner::handle_packed` runs the 8-stage pipeline over a packed
4
+ // metadata frame + a borrowed body and writes the packed decision into the
5
+ // caller-provided `out` buffer; `write_body_sections` serializes cookies/query
6
+ // and the optional metadata envelope. This is the hot path shared by both the
7
+ // packed-input fast path (`handle_request_packed`) and the full_sync family.
8
+ //
9
+ // The napi boundary (`Ingress`, the entry points) lives in mod.rs; this module
10
+ // is pure core logic (napi types only at the `Result`/`Error` boundaries used
11
+ // for error signalling).
12
+
13
+ use napi::bindgen_prelude::*;
14
+ use napi::Status;
15
+
16
+ use std::sync::Arc;
17
+
18
+ use super::IngressInner;
19
+ use crate::http::headers::HeaderRefs;
20
+ use crate::http::method::MethodKind;
21
+ use crate::ingress::output::{
22
+ compute_header_variant, HeaderFields, RateInfo, FLAG_BODY_TRUNCATED, FLAG_BODY_VALID_JSON,
23
+ FLAG_CORS_ALLOWED, FLAG_HAS_COOKIES, FLAG_HAS_QUERY, FLAG_HTTPS, FLAG_IS_PREFLIGHT,
24
+ FLAG_RATE_LIMITED, FLAG_SCHEMA_VALID, FLAG_TRUSTED_PROXY, OUT_DATA_START,
25
+ };
26
+ use crate::ingress::packed::read_section;
27
+ use crate::ingress::terminal::{
28
+ terminal_body_too_large, terminal_invalid_json, terminal_preflight_forbidden,
29
+ terminal_preflight_ok, terminal_rate_limited, terminal_schema_validation, terminal_simple,
30
+ };
31
+ use crate::ingress::time::rate_now_ms;
32
+
33
+ /// Precompiled request-body schema for an ingress instance.
34
+ ///
35
+ /// Holds the authoritative `jsonschema` crate validator plus an optional
36
+ /// zero-DOM `fast_schema` fast path, both compiled **once at construction** so
37
+ /// no per-request schema work happens. `validate` engages the fast path when
38
+ /// the schema uses only the supported keyword subset and falls back to the
39
+ /// DOM validator otherwise — identical semantics to
40
+ /// `json_schema::SchemaValidator::validate_doc`.
41
+ pub(crate) struct IngressSchema {
42
+ schema: Arc<jsonschema::Validator>,
43
+ fast: Option<Arc<crate::json::fast_schema::FastNode>>,
44
+ }
45
+
46
+ impl IngressSchema {
47
+ /// Compile the authoritative validator plus the optional fast path. Returns
48
+ /// the raw schema compile error string for the caller to format.
49
+ pub(crate) fn compile(schema_value: &serde_json::Value) -> std::result::Result<Self, String> {
50
+ // `should_validate_formats(true)`: see json_schema.rs — jsonschema 0.48
51
+ // disables formats by default; the fast path now honors format:email, so
52
+ // the authoritative DOM validator must too (byte-parity requirement).
53
+ // `.with_draft(Draft7)`: the fast path implements draft-07 and the crate
54
+ // default draft (no `$schema`) is 2020-12 — pin so both paths agree on
55
+ // schemas without a `$schema`. Declared `$schema` still overrides.
56
+ let compiled = match jsonschema::options()
57
+ .with_draft(jsonschema::Draft::Draft7)
58
+ .should_validate_formats(true)
59
+ .build(schema_value)
60
+ {
61
+ Ok(v) => v,
62
+ Err(e) => return Err(e.to_string()),
63
+ };
64
+ let fast = crate::json::fast_schema::compile(schema_value)
65
+ .ok()
66
+ .map(Arc::new);
67
+ Ok(Self {
68
+ schema: Arc::new(compiled),
69
+ fast,
70
+ })
71
+ }
72
+
73
+ /// Validate a request body against the precompiled schema.
74
+ #[inline]
75
+ pub(crate) fn validate(&self, bytes: &[u8]) -> bool {
76
+ if let Some(fast) = &self.fast {
77
+ return fast.is_valid_bytes(bytes);
78
+ }
79
+ match sonic_rs::from_slice::<serde_json::Value>(bytes) {
80
+ Ok(value) => self.schema.is_valid(&value),
81
+ Err(_) => false,
82
+ }
83
+ }
84
+
85
+ /// Whether the zero-DOM fast path is engaged for this schema.
86
+ ///
87
+ /// The fast path is used only when every keyword in the schema is in the
88
+ /// supported subset (otherwise `compile` leaves `fast` as `None` and the
89
+ /// DOM validator is authoritative). The ingress pipeline treats a fast-path
90
+ /// pass as BOTH schema validation AND the RFC-8259 well-formedness gate:
91
+ /// the structural walk rejects raw control bytes in strings (the one
92
+ /// violation the sonic gate closes that the walk didn't) and already
93
+ /// rejects every other malformed-JSON class, while matching sonic's
94
+ /// leniency (bad `\uXXXX` hex, lone surrogates, invalid UTF-8, `1e999`).
95
+ /// On a walk failure the sonic gate still splits 400 (malformed) vs 422
96
+ /// (schema reject). See rust/json/fast_schema/cursor.rs `raw_string`.
97
+ #[inline]
98
+ pub(crate) fn uses_fast_path(&self) -> bool {
99
+ self.fast.is_some()
100
+ }
101
+ }
102
+
103
+ impl IngressInner {
104
+ /// Process one request.
105
+ ///
106
+ /// - `input`: packed metadata frame (method byte + url/ip/rid/headers sections)
107
+ /// - `body_bytes`: the request body (empty when there is none)
108
+ /// - `out`: receives the packed decision header followed by JSON payloads
109
+ pub(crate) fn handle_packed(
110
+ &self,
111
+ input: &[u8],
112
+ body_bytes: &[u8],
113
+ out: &mut [u8],
114
+ ) -> Result<usize> {
115
+ if out.len() < OUT_DATA_START {
116
+ return Err(Error::new(Status::InvalidArg, "output buffer too small"));
117
+ }
118
+ if input.is_empty() {
119
+ return Err(Error::new(Status::InvalidArg, "input buffer too small"));
120
+ }
121
+
122
+ // ── 1. Parse the packed input frame ─────────────────────────
123
+ let mut pos = 1usize;
124
+ let mk = MethodKind::from_u8(input[0]);
125
+
126
+ let url_bytes = match read_section(input, &mut pos, self.limits.max_url_bytes) {
127
+ Ok(v) => v,
128
+ Err(_) => {
129
+ return Ok(terminal_simple(
130
+ out,
131
+ crate::ingress::output::ERR_CODE_BAD_REQUEST,
132
+ 414,
133
+ ))
134
+ }
135
+ };
136
+ let ip_bytes = match read_section(input, &mut pos, 128) {
137
+ Ok(v) => v,
138
+ Err(_) => {
139
+ return Ok(terminal_simple(
140
+ out,
141
+ crate::ingress::output::ERR_CODE_BAD_REQUEST,
142
+ 400,
143
+ ))
144
+ }
145
+ };
146
+ let rid_bytes = match read_section(input, &mut pos, 128) {
147
+ Ok(v) => v,
148
+ Err(_) => {
149
+ return Ok(terminal_simple(
150
+ out,
151
+ crate::ingress::output::ERR_CODE_BAD_REQUEST,
152
+ 400,
153
+ ))
154
+ }
155
+ };
156
+ let headers_packed = match read_section(input, &mut pos, self.limits.max_headers_bytes) {
157
+ Ok(v) => v,
158
+ Err(_) => {
159
+ return Ok(terminal_simple(
160
+ out,
161
+ crate::ingress::output::ERR_CODE_REQUEST_TOO_LARGE,
162
+ 431,
163
+ ))
164
+ }
165
+ };
166
+ // The per-request core (trust/IP/https, CORS, rate, body guard,
167
+ // schema, section serialization, output header) is shared with the
168
+ // C-ABI `castrum_ingress_handle_components` path (which skips JS-side
169
+ // frame assembly for URL/IP by passing them as `bun:ffi` cstrings).
170
+ self.handle_components(
171
+ mk,
172
+ url_bytes,
173
+ ip_bytes,
174
+ rid_bytes,
175
+ headers_packed,
176
+ body_bytes,
177
+ out,
178
+ )
179
+ }
180
+
181
+ /// Process one request from raw components.
182
+ ///
183
+ /// Shared by the packed-frame path (`handle_packed`) and the C-ABI
184
+ /// `castrum_ingress_handle_components` (which receives URL/IP as
185
+ /// NUL-terminated `bun:ffi` cstrings and the rid/headers/body as byte
186
+ /// slices, skipping the JS-side frame assembly). Runs stages 2-8 over the
187
+ /// parsed request components; parses the packed header block here (the
188
+ /// `is_options` flag and `max_headers` bound live on this instance).
189
+ ///
190
+ /// - `mk`: HTTP method kind
191
+ /// - `url_bytes` / `ip_bytes` / `rid_bytes`: raw request components
192
+ /// - `headers_packed`: packed header block (`[u16 count] {pairs}`)
193
+ /// - `body_bytes`: the request body (empty when there is none)
194
+ /// - `out`: receives the packed decision header followed by JSON payloads
195
+ #[allow(clippy::too_many_arguments)]
196
+ pub(crate) fn handle_components(
197
+ &self,
198
+ mk: MethodKind,
199
+ url_bytes: &[u8],
200
+ ip_bytes: &[u8],
201
+ rid_bytes: &[u8],
202
+ headers_packed: &[u8],
203
+ body_bytes: &[u8],
204
+ out: &mut [u8],
205
+ ) -> Result<usize> {
206
+ if out.len() < OUT_DATA_START {
207
+ return Err(Error::new(Status::InvalidArg, "output buffer too small"));
208
+ }
209
+ let is_options = mk == MethodKind::Options;
210
+ let headers = match HeaderRefs::parse(headers_packed, is_options, self.limits.max_headers) {
211
+ Ok(v) => v,
212
+ Err(_) => {
213
+ return Ok(terminal_simple(
214
+ out,
215
+ crate::ingress::output::ERR_CODE_REQUEST_TOO_LARGE,
216
+ 431,
217
+ ))
218
+ }
219
+ };
220
+
221
+ // ── 2. Trust, client IP, and HTTPS ──────────────────────────
222
+ let mut flags: u32 = 0;
223
+ let rate_active = self.rate.as_limiter().is_some();
224
+
225
+ // The resolved IP is consumed ONLY by the rate limiter (stage 4). When
226
+ // rate limiting is disabled we still need `peer_trusted` (for the
227
+ // FLAG_TRUSTED_PROXY bit + HTTPS detection), but we can skip the full
228
+ // socket-IP `IpAddr` parse entirely — `socket_is_trusted` returns
229
+ // false immediately when no trusted-proxy mode is configured (the
230
+ // common case) and only parses when it actually matters. This removes
231
+ // a `str::parse::<IpAddr>` + `trim` per request off the hot path.
232
+ let (resolved_ip, peer_trusted) = if rate_active {
233
+ crate::ingress::ip_trust::resolve_client_ip(
234
+ &self.proxy_trust,
235
+ ip_bytes,
236
+ headers.xff(),
237
+ headers.x_real_ip(),
238
+ )
239
+ } else {
240
+ let peer_trusted =
241
+ crate::ingress::ip_trust::socket_is_trusted(&self.proxy_trust, ip_bytes);
242
+ (
243
+ crate::ingress::ip_trust::ResolvedIp::Raw(crate::util::trim_ascii_whitespace(
244
+ ip_bytes,
245
+ )),
246
+ peer_trusted,
247
+ )
248
+ };
249
+
250
+ if peer_trusted {
251
+ flags |= FLAG_TRUSTED_PROXY;
252
+ }
253
+
254
+ if crate::ingress::proxy::detect_https(
255
+ self.https_fixed,
256
+ &self.proxy_trust,
257
+ url_bytes,
258
+ &headers,
259
+ peer_trusted,
260
+ ) {
261
+ flags |= FLAG_HTTPS;
262
+ }
263
+
264
+ // ── 3. CORS (may terminate on a preflight request) ─────────
265
+ if self.cors_enabled && headers.has_origin() {
266
+ let eval = self.cors.evaluate(mk, &headers);
267
+
268
+ if eval.preflight {
269
+ flags |= FLAG_IS_PREFLIGHT;
270
+ }
271
+ if eval.allowed {
272
+ flags |= FLAG_CORS_ALLOWED;
273
+ }
274
+
275
+ if eval.preflight {
276
+ let hv = if eval.allowed {
277
+ compute_header_variant(true, true, rate_active, false, false)
278
+ } else {
279
+ compute_header_variant(false, false, rate_active, false, true)
280
+ };
281
+ return Ok(if eval.allowed {
282
+ terminal_preflight_ok(flags, hv, out)
283
+ } else {
284
+ terminal_preflight_forbidden(flags, hv, out)
285
+ });
286
+ }
287
+ }
288
+
289
+ // The header variant for every non-preflight JSON response.
290
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
291
+ let success_hv = compute_header_variant(cors_ok, false, rate_active, false, true);
292
+
293
+ // ── 4. Rate limiting (may terminate with 429) ──────────────
294
+ let mut rate = RateInfo::default();
295
+ if let Some(limiter) = self.rate.as_limiter() {
296
+ let now = rate_now_ms();
297
+ let outcome = limiter.check_key(resolved_ip.rate_key(limiter.seed()), now);
298
+
299
+ rate.limit = limiter.limit();
300
+ rate.remaining = outcome.remaining;
301
+ rate.reset_ms = outcome.reset_ms;
302
+
303
+ if !outcome.allowed {
304
+ flags |= FLAG_RATE_LIMITED;
305
+ rate.retry_after_ms = outcome.reset_ms.saturating_sub(now);
306
+ let hv = compute_header_variant(cors_ok, false, true, true, true);
307
+ return Ok(terminal_rate_limited(flags, hv, rate, out));
308
+ }
309
+ }
310
+
311
+ // ── 5. Body size guard (may terminate with 413) ────────────
312
+ if self.guard_enabled && body_bytes.len() > self.max_body_bytes {
313
+ return Ok(terminal_body_too_large(flags, success_hv, rate, out));
314
+ }
315
+
316
+ // ── 6. JSON body validation & schema (400 / 422) ───────────
317
+ let has_body = !body_bytes.is_empty();
318
+ let enforce_json = if self.schema.is_some() {
319
+ mk.may_have_body() || has_body
320
+ } else {
321
+ self.require_json_body && (mk.may_have_body() || has_body)
322
+ };
323
+
324
+ if enforce_json {
325
+ if body_bytes.is_empty() {
326
+ return Ok(terminal_invalid_json(flags, success_hv, rate, out));
327
+ }
328
+
329
+ // Fused validity + schema pass, preserving the 400/422 split:
330
+ //
331
+ // * DOM fallback path (no fast node): `schema.validate` parses the
332
+ // body into a `serde_json::Value` first, so a pass already proves
333
+ // the body is well-formed JSON — we can skip the separate
334
+ // `json_valid_bytes` body scan on the happy path (one full-body
335
+ // pass instead of two). On a failure `json_valid_bytes` still
336
+ // distinguishes a malformed body (400) from a well-formed body
337
+ // that fails the schema (422), exactly as before.
338
+ // * Fast path (zero-DOM): the structural walk is now RFC-8259-strict
339
+ // (raw control bytes in strings are rejected — the one gap sonic
340
+ // closes that the walk didn't; it already rejects the other
341
+ // malformed-JSON classes and is lenient on exactly the same
342
+ // inputs sonic is), so a pass proves well-formedness too. The
343
+ // happy path is ONE body pass; on a failure `json_valid_bytes`
344
+ // still splits 400 (malformed) vs 422 (schema reject).
345
+ // * No schema (`require_json_body`): strict gate only.
346
+ match self.schema.as_ref() {
347
+ Some(schema) if !schema.uses_fast_path() => {
348
+ if schema.validate(body_bytes) {
349
+ flags |= FLAG_BODY_VALID_JSON;
350
+ flags |= FLAG_SCHEMA_VALID;
351
+ } else if !crate::json::json_ops::json_valid_bytes(body_bytes) {
352
+ return Ok(terminal_invalid_json(flags, success_hv, rate, out));
353
+ } else {
354
+ return Ok(terminal_schema_validation(flags, success_hv, rate, out));
355
+ }
356
+ }
357
+ Some(schema) => {
358
+ if schema.validate(body_bytes) {
359
+ flags |= FLAG_BODY_VALID_JSON;
360
+ flags |= FLAG_SCHEMA_VALID;
361
+ } else if !crate::json::json_ops::json_valid_bytes(body_bytes) {
362
+ return Ok(terminal_invalid_json(flags, success_hv, rate, out));
363
+ } else {
364
+ // Body is well-formed JSON (confirmed by the sonic
365
+ // re-check) but fails the schema → 422. bodyValidJson
366
+ // stays true (matches the pre-single-pass behavior,
367
+ // where the gate flag was set before the schema check).
368
+ flags |= FLAG_BODY_VALID_JSON;
369
+ return Ok(terminal_schema_validation(flags, success_hv, rate, out));
370
+ }
371
+ }
372
+ None => {
373
+ if !crate::json::json_ops::json_valid_bytes(body_bytes) {
374
+ return Ok(terminal_invalid_json(flags, success_hv, rate, out));
375
+ }
376
+ flags |= FLAG_BODY_VALID_JSON;
377
+ // No schema → validation trivially passes. Setting the flag
378
+ // keeps `schemaValid` meaning "schema passed" for consumers
379
+ // that gate on it (e.g. handlers.ts jsonWriteHandler).
380
+ flags |= FLAG_SCHEMA_VALID;
381
+ }
382
+ }
383
+ }
384
+
385
+ // ── 7. Serialize cookies / query / metadata into the output ─
386
+ // Extract the query string once and reuse it for both the size guard
387
+ // and the JSON serialization, instead of scanning the URL twice.
388
+ let raw_query: &[u8] = if self.parse_query {
389
+ let q = crate::ingress::proxy::extract_query(url_bytes);
390
+ if q.len() > self.limits.max_query_bytes {
391
+ return Ok(terminal_simple(
392
+ out,
393
+ crate::ingress::output::ERR_CODE_BAD_REQUEST,
394
+ 414,
395
+ ));
396
+ }
397
+ q
398
+ } else {
399
+ &[]
400
+ };
401
+
402
+ let sections =
403
+ match self.write_body_sections(url_bytes, rid_bytes, &headers, raw_query, out) {
404
+ Ok(s) => s,
405
+ Err(_) => {
406
+ return Ok(terminal_simple(
407
+ out,
408
+ crate::ingress::output::ERR_CODE_BAD_REQUEST,
409
+ 400,
410
+ ))
411
+ }
412
+ };
413
+
414
+ if sections.cookies_json_len > 2 {
415
+ flags |= FLAG_HAS_COOKIES;
416
+ }
417
+ if sections.query_json_len > 2 {
418
+ flags |= FLAG_HAS_QUERY;
419
+ }
420
+ if sections.truncated || (self.emit_metadata_json && sections.body_json_len == 0) {
421
+ flags |= FLAG_BODY_TRUNCATED;
422
+ }
423
+
424
+ // ── 8. Write the output header ──────────────────────────────
425
+ Ok(HeaderFields::ok(
426
+ flags,
427
+ rate,
428
+ sections.cookies_json_len,
429
+ sections.query_json_len,
430
+ success_hv,
431
+ sections.body_json_len as u32,
432
+ )
433
+ .write(out))
434
+ }
435
+
436
+ /// Serialize cookies→JSON and query→JSON (plus the optional metadata
437
+ /// envelope) into the output buffer. Returns each section's length and
438
+ /// whether any output had to be truncated.
439
+ ///
440
+ /// Errors only when the query string is malformed (mapped to a 400 by the
441
+ /// caller); a too-small output buffer is surfaced via `truncated` so the
442
+ /// consumer never mistakes truncated data for complete data.
443
+ fn write_body_sections(
444
+ &self,
445
+ url_bytes: &[u8],
446
+ rid_bytes: &[u8],
447
+ headers: &HeaderRefs<'_>,
448
+ raw_query: &[u8],
449
+ out: &mut [u8],
450
+ ) -> Result<BodySections> {
451
+ let mut data_pos = OUT_DATA_START;
452
+ let mut truncated = false;
453
+
454
+ // ── Cookies → JSON ──
455
+ let cookies_json_len: u32 = if self.parse_cookies {
456
+ match headers.cookie() {
457
+ Some(cookie) if cookie.len() <= self.limits.max_cookie_bytes => {
458
+ match crate::json::json_ser::cookie_json_into_slice(
459
+ cookie,
460
+ &mut out[data_pos..],
461
+ self.limits.max_pairs,
462
+ ) {
463
+ Ok(written) => written as u32,
464
+ Err(_) => {
465
+ truncated = true;
466
+ 0
467
+ }
468
+ }
469
+ }
470
+ _ => 0,
471
+ }
472
+ } else {
473
+ 0
474
+ };
475
+ let cookies_start = data_pos;
476
+ data_pos += cookies_json_len as usize;
477
+
478
+ // ── Query → JSON (direct, zero-alloc: no intermediate packed buffer,
479
+ // no second parse of packed pairs; the query was already extracted
480
+ // once by the caller for the size guard) ──
481
+ let query_json_len: u32 =
482
+ if self.parse_query && !raw_query.is_empty() && data_pos < out.len() {
483
+ match crate::json::json_ser::query_to_json_into_slice(
484
+ raw_query,
485
+ &mut out[data_pos..],
486
+ self.limits.max_pairs,
487
+ ) {
488
+ Ok(written) => written as u32,
489
+ Err(crate::json::json_ser::QueryJsonError::Malformed) => {
490
+ return Err(Error::from_reason("query parse failed"))
491
+ }
492
+ Err(crate::json::json_ser::QueryJsonError::BufferTooSmall) => {
493
+ truncated = true;
494
+ 0
495
+ }
496
+ }
497
+ } else {
498
+ 0
499
+ };
500
+ let query_start = data_pos;
501
+ data_pos += query_json_len as usize;
502
+
503
+ // ── Optional metadata envelope ──
504
+ let body_json_len = if self.emit_metadata_json {
505
+ crate::json::json_ser::write_full_body_json(
506
+ out,
507
+ data_pos,
508
+ rid_bytes,
509
+ crate::ingress::proxy::extract_path(url_bytes),
510
+ cookies_start,
511
+ cookies_json_len as usize,
512
+ query_start,
513
+ query_json_len as usize,
514
+ )
515
+ } else {
516
+ 0
517
+ };
518
+
519
+ Ok(BodySections {
520
+ cookies_json_len,
521
+ query_json_len,
522
+ body_json_len,
523
+ truncated,
524
+ })
525
+ }
526
+ }
527
+
528
+ /// Result of serializing cookies/query/metadata into the output buffer.
529
+ struct BodySections {
530
+ cookies_json_len: u32,
531
+ query_json_len: u32,
532
+ body_json_len: usize,
533
+ truncated: bool,
534
+ }
@@ -0,0 +1,138 @@
1
+ // rust/ingress/proxy.rs — Proxy trust, IP resolution, and HTTPS detection
2
+
3
+ use crate::http::headers::HeaderRefs;
4
+ use crate::ingress::ip_trust::ProxyTrustMode;
5
+ use crate::util::trim_ascii_whitespace;
6
+
7
+ /// Detect whether the request is HTTPS based on URL, proxy trust, and headers.
8
+ #[inline]
9
+ pub fn detect_https(
10
+ https_fixed: Option<bool>,
11
+ proxy_trust: &ProxyTrustMode,
12
+ url: &[u8],
13
+ headers: &HeaderRefs<'_>,
14
+ peer_trusted: bool,
15
+ ) -> bool {
16
+ if let Some(v) = https_fixed {
17
+ return v;
18
+ }
19
+
20
+ if url.starts_with(b"https://") || url.starts_with(b"wss://") {
21
+ return true;
22
+ }
23
+
24
+ if !proxy_trust.is_none() && peer_trusted && headers.has_xfp() {
25
+ if let Some(xfp) = headers.x_forwarded_proto() {
26
+ return trim_ascii_whitespace(xfp) == b"https";
27
+ }
28
+ }
29
+
30
+ false
31
+ }
32
+
33
+ // ── URL helpers ───────────────────────────────────────────────────
34
+
35
+ /// Extract the path component from a URL.
36
+ #[inline]
37
+ pub fn extract_path(url: &[u8]) -> &[u8] {
38
+ let search_start = if url.starts_with(b"https://") {
39
+ 8
40
+ } else if url.starts_with(b"http://") {
41
+ 7
42
+ } else {
43
+ 0
44
+ };
45
+
46
+ let search_space = &url[search_start..];
47
+
48
+ let path_start = match memchr::memchr(b'/', search_space) {
49
+ Some(i) => search_start + i,
50
+ None => return b"/",
51
+ };
52
+
53
+ let after_path = &url[path_start..];
54
+ let end = memchr::memchr2(b'?', b'#', after_path).unwrap_or(after_path.len());
55
+
56
+ &url[path_start..path_start + end]
57
+ }
58
+
59
+ /// Extract the query string from a URL (without the leading '?').
60
+ #[inline]
61
+ pub fn extract_query(url: &[u8]) -> &[u8] {
62
+ let search_start = if url.starts_with(b"https://") {
63
+ 8
64
+ } else if url.starts_with(b"http://") {
65
+ 7
66
+ } else {
67
+ 0
68
+ };
69
+
70
+ let search_space = &url[search_start..];
71
+
72
+ if let Some(i) = memchr::memchr2(b'?', b'#', search_space) {
73
+ if search_space[i] == b'?' {
74
+ let after_q = &search_space[i + 1..];
75
+ let frag = memchr::memchr(b'#', after_q).unwrap_or(after_q.len());
76
+ &after_q[..frag]
77
+ } else {
78
+ &[]
79
+ }
80
+ } else {
81
+ &[]
82
+ }
83
+ }
84
+
85
+ #[cfg(test)]
86
+ mod tests {
87
+ use super::*;
88
+ use crate::http::headers::HeaderRefs;
89
+ use crate::ingress::ip_trust::ProxyTrustMode;
90
+
91
+ #[test]
92
+ fn extract_path_variants() {
93
+ assert_eq!(extract_path(b"/api/users"), b"/api/users");
94
+ assert_eq!(extract_path(b"/api/users?page=2"), b"/api/users");
95
+ assert_eq!(extract_path(b"https://example.com/api?x=1"), b"/api");
96
+ assert_eq!(extract_path(b"http://example.com"), b"/");
97
+ }
98
+
99
+ #[test]
100
+ fn extract_query_variants() {
101
+ assert_eq!(extract_query(b"/api?page=2#frag"), b"page=2");
102
+ assert_eq!(extract_query(b"https://x.com/a?b=c"), b"b=c");
103
+ assert_eq!(extract_query(b"/no-query"), b"");
104
+ }
105
+
106
+ #[test]
107
+ fn detect_https_fixed_and_url() {
108
+ let h = HeaderRefs::empty();
109
+ assert!(detect_https(
110
+ Some(true),
111
+ &ProxyTrustMode::None,
112
+ b"/x",
113
+ &h,
114
+ false
115
+ ));
116
+ assert!(!detect_https(
117
+ Some(false),
118
+ &ProxyTrustMode::None,
119
+ b"/x",
120
+ &h,
121
+ false
122
+ ));
123
+ assert!(detect_https(
124
+ None,
125
+ &ProxyTrustMode::None,
126
+ b"https://x.com",
127
+ &h,
128
+ false
129
+ ));
130
+ assert!(!detect_https(
131
+ None,
132
+ &ProxyTrustMode::None,
133
+ b"http://x.com",
134
+ &h,
135
+ false
136
+ ));
137
+ }
138
+ }