castrum 0.9.2 → 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,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
|
+
}
|