castrum 0.7.0

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 (86) hide show
  1. package/Cargo.toml +73 -0
  2. package/README.md +432 -0
  3. package/bench.ts +3 -0
  4. package/castrum.linux-x64-gnu.node +0 -0
  5. package/index.ts +24 -0
  6. package/package.json +94 -0
  7. package/rust/batch.rs +195 -0
  8. package/rust/cookie_parser.rs +70 -0
  9. package/rust/cors.rs +263 -0
  10. package/rust/hashing.rs +164 -0
  11. package/rust/headers.rs +234 -0
  12. package/rust/hmac_sha256.rs +113 -0
  13. package/rust/http_parser.rs +132 -0
  14. package/rust/ingress.rs +616 -0
  15. package/rust/ingress_constants.rs +89 -0
  16. package/rust/ip_trust.rs +185 -0
  17. package/rust/json_ops.rs +66 -0
  18. package/rust/json_patch_ops.rs +25 -0
  19. package/rust/json_schema.rs +151 -0
  20. package/rust/json_ser.rs +429 -0
  21. package/rust/lib.rs +48 -0
  22. package/rust/method.rs +141 -0
  23. package/rust/mime_lookup.rs +76 -0
  24. package/rust/output.rs +238 -0
  25. package/rust/proxy.rs +90 -0
  26. package/rust/query_parser.rs +141 -0
  27. package/rust/random_token.rs +52 -0
  28. package/rust/rate_limit.rs +203 -0
  29. package/rust/terminal.rs +95 -0
  30. package/rust/unit_tests.rs +843 -0
  31. package/rust/url_codec.rs +133 -0
  32. package/rust/util.rs +537 -0
  33. package/rust/validation.rs +107 -0
  34. package/rust/websocket.rs +23 -0
  35. package/src/baseline/index.ts +12 -0
  36. package/src/baseline/tasks/cookie.ts +22 -0
  37. package/src/baseline/tasks/hashing.ts +18 -0
  38. package/src/baseline/tasks/hmac.ts +38 -0
  39. package/src/baseline/tasks/http.ts +62 -0
  40. package/src/baseline/tasks/json-patch.ts +12 -0
  41. package/src/baseline/tasks/json.ts +50 -0
  42. package/src/baseline/tasks/mime.ts +5 -0
  43. package/src/baseline/tasks/query.ts +14 -0
  44. package/src/baseline/tasks/token.ts +15 -0
  45. package/src/baseline/tasks/url.ts +11 -0
  46. package/src/baseline/tasks/validation.ts +19 -0
  47. package/src/baseline/tasks/websocket.ts +14 -0
  48. package/src/bench/assert.ts +70 -0
  49. package/src/bench/checks.ts +227 -0
  50. package/src/bench/checksum.ts +27 -0
  51. package/src/bench/comparisons.ts +70 -0
  52. package/src/bench/concurrent-worker.ts +135 -0
  53. package/src/bench/fixtures.ts +233 -0
  54. package/src/bench/index.ts +2 -0
  55. package/src/bench/measure.ts +177 -0
  56. package/src/bench/now.ts +3 -0
  57. package/src/bench/report.ts +79 -0
  58. package/src/bench/run.ts +57 -0
  59. package/src/bench/tasks/complex.ts +232 -0
  60. package/src/bench/tasks/concurrent.ts +213 -0
  61. package/src/bench/tasks/cookie.ts +44 -0
  62. package/src/bench/tasks/hashing.ts +33 -0
  63. package/src/bench/tasks/hmac.ts +36 -0
  64. package/src/bench/tasks/http.ts +50 -0
  65. package/src/bench/tasks/index.ts +52 -0
  66. package/src/bench/tasks/json-patch.ts +21 -0
  67. package/src/bench/tasks/json.ts +33 -0
  68. package/src/bench/tasks/mime.ts +21 -0
  69. package/src/bench/tasks/query.ts +44 -0
  70. package/src/bench/tasks/stress.ts +142 -0
  71. package/src/bench/tasks/token.ts +20 -0
  72. package/src/bench/tasks/url.ts +34 -0
  73. package/src/bench/tasks/validation.ts +57 -0
  74. package/src/bench/tasks/websocket.ts +21 -0
  75. package/src/bench/types.ts +60 -0
  76. package/src/data/json-rows.ts +33 -0
  77. package/src/ingress/constants.ts +56 -0
  78. package/src/ingress/fast.ts +1082 -0
  79. package/src/ingress/handlers.ts +1202 -0
  80. package/src/ingress/index.ts +457 -0
  81. package/src/ingress/packed-input.ts +59 -0
  82. package/src/native/index.ts +207 -0
  83. package/src/rust-ffi/index.ts +555 -0
  84. package/src/shared/bytes.ts +36 -0
  85. package/src/shared/json.ts +18 -0
  86. package/src/shared/packed.ts +316 -0
@@ -0,0 +1,616 @@
1
+ #![allow(clippy::too_many_arguments)]
2
+
3
+ use napi::bindgen_prelude::*;
4
+ use napi::Status;
5
+ use napi_derive::napi;
6
+
7
+ use std::sync::{Arc, OnceLock};
8
+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
9
+
10
+ use crate::cors::CorsOptions;
11
+ use crate::headers::HeaderRefs;
12
+ use crate::method::MethodKind;
13
+ use crate::output::{
14
+ compute_header_variant, write_output_header, FLAG_BODY_TRUNCATED,
15
+ FLAG_BODY_VALID_JSON, FLAG_CORS_ALLOWED, FLAG_HTTPS, FLAG_IS_PREFLIGHT,
16
+ FLAG_RATE_LIMITED, FLAG_SCHEMA_VALID, FLAG_TRUSTED_PROXY, OUT_DATA_START,
17
+ };
18
+ use crate::terminal::{
19
+ terminal_body_too_large, terminal_invalid_json, terminal_preflight_forbidden,
20
+ terminal_preflight_ok, terminal_rate_limited, terminal_schema_validation,
21
+ terminal_simple,
22
+ };
23
+
24
+ #[napi(object)]
25
+ pub struct RateLimitOptions {
26
+ pub limit: Option<u32>,
27
+ pub window_ms: Option<u32>,
28
+ pub max_entries: Option<u32>,
29
+ }
30
+
31
+ #[napi(object)]
32
+ pub struct TrustedProxyOptions {
33
+ pub enabled: Option<bool>,
34
+ pub networks: Option<Vec<String>>,
35
+ }
36
+
37
+ #[napi(object)]
38
+ pub struct IngressLimitsOptions {
39
+ pub max_url_bytes: Option<u32>,
40
+ pub max_query_bytes: Option<u32>,
41
+ pub max_cookie_bytes: Option<u32>,
42
+ pub max_headers_bytes: Option<u32>,
43
+ pub max_headers: Option<u32>,
44
+ pub max_pairs: Option<u32>,
45
+ }
46
+
47
+ #[napi(object)]
48
+ pub struct IngressOptions {
49
+ pub trust_proxy: Option<bool>,
50
+ pub trusted_proxies: Option<TrustedProxyOptions>,
51
+ pub parse_cookies: Option<bool>,
52
+ pub parse_query: Option<bool>,
53
+ pub require_json_body: Option<bool>,
54
+ pub schema: Option<Uint8Array>,
55
+ pub cors: Option<CorsOptions>,
56
+ pub rate_limit: Option<RateLimitOptions>,
57
+ pub https: Option<bool>,
58
+ pub max_body_bytes: Option<u32>,
59
+ pub enable_body_size_guard: Option<bool>,
60
+ pub emit_metadata_json: Option<bool>,
61
+ pub limits: Option<IngressLimitsOptions>,
62
+ }
63
+
64
+ // ── Limits ────────────────────────────────────────────────────────
65
+ #[derive(Clone)]
66
+ pub(crate) struct Limits {
67
+ pub max_url_bytes: usize,
68
+ pub max_query_bytes: usize,
69
+ pub max_cookie_bytes: usize,
70
+ pub max_headers_bytes: usize,
71
+ pub max_headers: usize,
72
+ pub max_pairs: usize,
73
+ }
74
+
75
+ impl Default for Limits {
76
+ fn default() -> Self {
77
+ Self {
78
+ max_url_bytes: 65536,
79
+ max_query_bytes: 16384,
80
+ max_cookie_bytes: 8192,
81
+ max_headers_bytes: 65536,
82
+ max_headers: 100,
83
+ max_pairs: 1024,
84
+ }
85
+ }
86
+ }
87
+
88
+ impl Limits {
89
+ pub(crate) fn from_options(opts: Option<IngressLimitsOptions>) -> Self {
90
+ let d = Self::default();
91
+ let Some(o) = opts else { return d };
92
+
93
+ Self {
94
+ max_url_bytes: o.max_url_bytes.map(|v| v as usize).unwrap_or(d.max_url_bytes),
95
+ max_query_bytes: o.max_query_bytes.map(|v| v as usize).unwrap_or(d.max_query_bytes),
96
+ max_cookie_bytes: o.max_cookie_bytes.map(|v| v as usize).unwrap_or(d.max_cookie_bytes),
97
+ max_headers_bytes: o.max_headers_bytes.map(|v| v as usize).unwrap_or(d.max_headers_bytes),
98
+ max_headers: o.max_headers.map(|v| v as usize).unwrap_or(d.max_headers),
99
+ max_pairs: o.max_pairs.map(|v| v as usize).unwrap_or(d.max_pairs),
100
+ }
101
+ }
102
+ }
103
+
104
+ // ── Time helpers ──────────────────────────────────────────────────
105
+ static START: OnceLock<Instant> = OnceLock::new();
106
+ static WALL_OFFSET_MS: OnceLock<i128> = OnceLock::new();
107
+
108
+ #[inline(always)]
109
+ fn monotonic_ms() -> u128 {
110
+ START.get_or_init(Instant::now).elapsed().as_millis()
111
+ }
112
+
113
+ #[inline(always)]
114
+ fn wall_now_ms() -> u64 {
115
+ SystemTime::now()
116
+ .duration_since(UNIX_EPOCH)
117
+ .map(|d| d.as_millis() as u64)
118
+ .unwrap_or(0)
119
+ }
120
+
121
+ #[inline(always)]
122
+ fn wall_offset_ms() -> i128 {
123
+ *WALL_OFFSET_MS.get_or_init(|| wall_now_ms() as i128 - monotonic_ms() as i128)
124
+ }
125
+
126
+ #[inline(always)]
127
+ fn rate_now_ms() -> u64 {
128
+ let v = monotonic_ms() as i128 + wall_offset_ms();
129
+ if v < 0 { 0 } else { v as u64 }
130
+ }
131
+
132
+ // ── Packed input helpers (extracted from old ingress.rs) ──────────
133
+ #[inline]
134
+ fn read_u32_at(input: &[u8], pos: &mut usize) -> Result<usize> {
135
+ if *pos + 4 > input.len() {
136
+ return Err(Error::from_reason("packed input: truncated u32"));
137
+ }
138
+ let v = u32::from_le_bytes([
139
+ input[*pos], input[*pos + 1], input[*pos + 2], input[*pos + 3],
140
+ ]) as usize;
141
+ *pos += 4;
142
+ Ok(v)
143
+ }
144
+
145
+ #[inline]
146
+ fn read_section<'a>(input: &'a [u8], pos: &mut usize, max: usize) -> Result<&'a [u8]> {
147
+ let len = read_u32_at(input, pos)?;
148
+ if len > max {
149
+ return Err(Error::from_reason("packed input: section too large"));
150
+ }
151
+ let end = pos
152
+ .checked_add(len)
153
+ .ok_or_else(|| Error::from_reason("packed input: length overflow"))?;
154
+ if end > input.len() {
155
+ return Err(Error::from_reason("packed input: truncated section"));
156
+ }
157
+ let slice = &input[*pos..end];
158
+ *pos = end;
159
+ Ok(slice)
160
+ }
161
+
162
+ // ── Ingress state ─────────────────────────────────────────────────
163
+ #[derive(Clone)]
164
+ pub(crate) struct IngressInner {
165
+ pub https_fixed: Option<bool>,
166
+ pub max_body_bytes: usize,
167
+ pub proxy_trust: crate::ip_trust::ProxyTrustMode,
168
+ pub parse_cookies: bool,
169
+ pub parse_query: bool,
170
+ pub require_json_body: bool,
171
+ pub guard_enabled: bool,
172
+ pub emit_metadata_json: bool,
173
+ pub cors_enabled: bool,
174
+ pub cors: crate::cors::CorsEngine,
175
+ pub rate_enabled: bool,
176
+ pub rate_limiter: Option<Arc<crate::rate_limit::KeyedRateLimiter>>,
177
+ pub schema: Option<Arc<jsonschema::Validator>>,
178
+ pub limits: Limits,
179
+ }
180
+
181
+ #[napi]
182
+ pub struct Ingress {
183
+ inner: Arc<IngressInner>,
184
+ }
185
+
186
+ #[napi]
187
+ impl Ingress {
188
+ #[napi(constructor)]
189
+ pub fn new(options: IngressOptions) -> Result<Self> {
190
+ let parse_cookies = options.parse_cookies.unwrap_or(false);
191
+ let parse_query = options.parse_query.unwrap_or(false);
192
+ let require_json_body = options.require_json_body.unwrap_or(false);
193
+ let max_body_bytes = options.max_body_bytes.unwrap_or(1_048_576) as usize;
194
+ let guard_enabled = options.enable_body_size_guard.unwrap_or(true);
195
+ let emit_metadata_json = options.emit_metadata_json.unwrap_or(false);
196
+
197
+ let proxy_trust = if let Some(tp) = options.trusted_proxies {
198
+ crate::ip_trust::ProxyTrustMode::from_config(tp.enabled.unwrap_or(false), tp.networks)?
199
+ } else if options.trust_proxy.unwrap_or(false) {
200
+ crate::ip_trust::ProxyTrustMode::All
201
+ } else {
202
+ crate::ip_trust::ProxyTrustMode::None
203
+ };
204
+
205
+ let schema = if let Some(schema_bytes) = options.schema {
206
+ let schema_str = std::str::from_utf8(&schema_bytes)
207
+ .map_err(|_| Error::new(Status::InvalidArg, "Invalid UTF-8 in schema"))?;
208
+ let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
209
+ .map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
210
+ let compiled = jsonschema::validator_for(&schema_value)
211
+ .map_err(|e| Error::new(Status::InvalidArg, format!("Schema compile error: {}", e)))?;
212
+ Some(Arc::new(compiled))
213
+ } else {
214
+ None
215
+ };
216
+
217
+ let cors_enabled = options.cors.is_some();
218
+ let cors = crate::cors::CorsEngine::from_options(options.cors)?;
219
+
220
+ let (rate_enabled, rate_limiter) = if let Some(rl_opts) = options.rate_limit {
221
+ let limit = rl_opts.limit.unwrap_or(0);
222
+ let window_ms = rl_opts.window_ms.unwrap_or(1000);
223
+ if limit == 0 || limit == u32::MAX {
224
+ (false, None)
225
+ } else {
226
+ let max_entries = rl_opts.max_entries.map(|v| v as usize);
227
+ // Shared per-configuration across the process: prevents a client
228
+ // from bypassing a per-IP limit by spreading requests across routes.
229
+ let limiter =
230
+ crate::rate_limit::shared_limiter(limit, window_ms, max_entries);
231
+ (true, Some(limiter))
232
+ }
233
+ } else {
234
+ (false, None)
235
+ };
236
+
237
+ let limits = Limits::from_options(options.limits);
238
+
239
+ Ok(Self {
240
+ inner: Arc::new(IngressInner {
241
+ https_fixed: options.https,
242
+ max_body_bytes,
243
+ proxy_trust,
244
+ parse_cookies,
245
+ parse_query,
246
+ require_json_body,
247
+ guard_enabled,
248
+ emit_metadata_json,
249
+ cors_enabled,
250
+ cors,
251
+ rate_enabled,
252
+ rate_limiter,
253
+ schema,
254
+ limits,
255
+ }),
256
+ })
257
+ }
258
+
259
+ /// Production API:
260
+ /// input = packed metadata frame
261
+ /// body = separate zero-copy body
262
+ /// output = response decision buffer
263
+ #[napi(ts_args_type = "input: Uint8Array, body: Uint8Array | null, output: Uint8Array")]
264
+ pub fn handle_request_packed(
265
+ &self,
266
+ input: Uint8Array,
267
+ body: Option<Uint8Array>,
268
+ mut output: Uint8Array,
269
+ ) -> Result<u32> {
270
+ let inner: &IngressInner = self.inner.as_ref();
271
+
272
+ crate::util::run_packed_into(&input, &mut output, move |inp, out| {
273
+ match body {
274
+ Some(b) => {
275
+ let b_bytes = b.as_ref();
276
+ if crate::util::slices_overlap(b_bytes, out) {
277
+ let owned = b_bytes.to_vec();
278
+ inner.handle_packed(inp, &owned, out)
279
+ } else {
280
+ inner.handle_packed(inp, b_bytes, out)
281
+ }
282
+ }
283
+ None => inner.handle_packed(inp, &[], out),
284
+ }
285
+ })
286
+ }
287
+
288
+ /// Optimized sync API:
289
+ /// Accepts raw JS values (no binary packing in JS), packs them in Rust,
290
+ /// and processes them synchronously.
291
+ /// This is the fastest path for simple requests.
292
+ #[napi(ts_args_type = "methodKind: number, url: string, ip: string, requestId: string, headers: Array<[string, string]>, body: Uint8Array | null, outputBufferSize?: number")]
293
+ pub fn handle_request_full_sync(
294
+ &self,
295
+ method_kind: u32,
296
+ url: String,
297
+ ip: String,
298
+ request_id: String,
299
+ headers: Vec<Vec<String>>,
300
+ body: Option<Uint8Array>,
301
+ output_buffer_size: Option<u32>,
302
+ ) -> Result<Uint8Array> {
303
+ let inner: &IngressInner = self.inner.as_ref();
304
+
305
+ let url_bytes = url.into_bytes();
306
+ let ip_bytes = ip.into_bytes();
307
+ let rid_bytes = request_id.into_bytes();
308
+
309
+ let header_pairs: Vec<(String, String)> = headers
310
+ .into_iter()
311
+ .filter_map(|h| {
312
+ if h.len() >= 2 {
313
+ Some((h[0].clone(), h[1].clone()))
314
+ } else {
315
+ None
316
+ }
317
+ })
318
+ .collect();
319
+
320
+ let body_bytes = body.map(|b| b.as_ref().to_vec()).unwrap_or_default();
321
+ let output_size = output_buffer_size.unwrap_or(262_144).max(OUT_DATA_START as u32) as usize;
322
+
323
+ // Build packed input in Rust — no JS-side encoder.encodeInto() needed
324
+ let packed = build_packed_input_sync(method_kind as u8, &url_bytes, &ip_bytes, &rid_bytes, &header_pairs);
325
+
326
+ let mut out = vec![0u8; output_size];
327
+ let written = inner.handle_packed(&packed, &body_bytes, &mut out)?;
328
+ out.truncate(written);
329
+ Ok(Uint8Array::new(out))
330
+ }
331
+ }
332
+
333
+ /// Build the full packed input buffer from raw components (sync version).
334
+ fn build_packed_input_sync(
335
+ method_kind: u8,
336
+ url_bytes: &[u8],
337
+ ip_bytes: &[u8],
338
+ rid_bytes: &[u8],
339
+ headers: &[(String, String)],
340
+ ) -> Vec<u8> {
341
+ let mut buf = Vec::with_capacity(512);
342
+
343
+ // Method kind (1 byte)
344
+ buf.push(method_kind);
345
+
346
+ // URL section: u32le length-prefixed
347
+ buf.extend_from_slice(&(url_bytes.len() as u32).to_le_bytes());
348
+ buf.extend_from_slice(url_bytes);
349
+
350
+ // IP section: u32le length-prefixed
351
+ buf.extend_from_slice(&(ip_bytes.len() as u32).to_le_bytes());
352
+ buf.extend_from_slice(ip_bytes);
353
+
354
+ // Request ID section: u32le length-prefixed
355
+ buf.extend_from_slice(&(rid_bytes.len() as u32).to_le_bytes());
356
+ buf.extend_from_slice(rid_bytes);
357
+
358
+ // Headers section: u32le length-prefixed
359
+ let headers_len_pos = buf.len();
360
+ buf.extend_from_slice(&0u32.to_le_bytes()); // placeholder
361
+ let headers_start = buf.len();
362
+
363
+ // Write header count (u16le)
364
+ buf.extend_from_slice(&(headers.len() as u16).to_le_bytes());
365
+
366
+ for (name, value) in headers {
367
+ let name_bytes = name.as_bytes();
368
+ buf.extend_from_slice(&(name_bytes.len() as u16).to_le_bytes());
369
+ buf.extend_from_slice(name_bytes);
370
+ let value_bytes = value.as_bytes();
371
+ buf.extend_from_slice(&(value_bytes.len() as u32).to_le_bytes());
372
+ buf.extend_from_slice(value_bytes);
373
+ }
374
+
375
+ // Patch the header section length
376
+ let headers_len = buf.len() - headers_start;
377
+ buf[headers_len_pos..headers_len_pos + 4].copy_from_slice(&(headers_len as u32).to_le_bytes());
378
+
379
+ buf
380
+ }
381
+
382
+ impl IngressInner {
383
+ pub(crate) fn handle_packed(&self, input: &[u8], body_bytes: &[u8], out: &mut [u8]) -> Result<usize> {
384
+ if out.len() < OUT_DATA_START {
385
+ return Err(Error::new(Status::InvalidArg, "output buffer too small"));
386
+ }
387
+ if input.is_empty() {
388
+ return Err(Error::new(Status::InvalidArg, "input buffer too small"));
389
+ }
390
+
391
+ // ── Parse packed input ──────────────────────────────────────
392
+ let mut pos = 1usize;
393
+ let mk = MethodKind::from_u8(input[0]);
394
+ let is_options = mk == MethodKind::Options;
395
+
396
+ let url_bytes = match read_section(input, &mut pos, self.limits.max_url_bytes) {
397
+ Ok(v) => v,
398
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_BAD_REQUEST, 414),
399
+ };
400
+
401
+ let ip_bytes = match read_section(input, &mut pos, 128) {
402
+ Ok(v) => v,
403
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_BAD_REQUEST, 400),
404
+ };
405
+
406
+ let rid_bytes = match read_section(input, &mut pos, 128) {
407
+ Ok(v) => v,
408
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_BAD_REQUEST, 400),
409
+ };
410
+
411
+ let headers_packed = match read_section(input, &mut pos, self.limits.max_headers_bytes) {
412
+ Ok(v) => v,
413
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_REQUEST_TOO_LARGE, 431),
414
+ };
415
+
416
+ let headers = match HeaderRefs::parse(headers_packed, is_options, self.limits.max_headers) {
417
+ Ok(v) => v,
418
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_REQUEST_TOO_LARGE, 431),
419
+ };
420
+
421
+ // ── Flags & rate limiting prep ────────────────────────────
422
+ let mut flags: u32 = 0;
423
+ let rate_active = self.rate_enabled && self.rate_limiter.is_some();
424
+
425
+ let (resolved_ip, peer_trusted) =
426
+ crate::ip_trust::resolve_client_ip(&self.proxy_trust, ip_bytes, headers.xff(), headers.x_real_ip());
427
+
428
+ if peer_trusted {
429
+ flags |= FLAG_TRUSTED_PROXY;
430
+ }
431
+
432
+ if crate::proxy::detect_https(self.https_fixed, &self.proxy_trust, url_bytes, &headers, peer_trusted) {
433
+ flags |= FLAG_HTTPS;
434
+ }
435
+
436
+ // ── CORS evaluation ─────────────────────────────────────────
437
+ if self.cors_enabled && headers.has_origin() {
438
+ let eval = self.cors.evaluate(mk, &headers);
439
+
440
+ if eval.preflight {
441
+ flags |= FLAG_IS_PREFLIGHT;
442
+ }
443
+ if eval.allowed {
444
+ flags |= FLAG_CORS_ALLOWED;
445
+ }
446
+
447
+ if eval.preflight {
448
+ return if eval.allowed {
449
+ let hv = compute_header_variant(true, true, rate_active, false, false);
450
+ terminal_preflight_ok(flags, hv, out)
451
+ } else {
452
+ let hv = compute_header_variant(false, false, rate_active, false, true);
453
+ terminal_preflight_forbidden(flags, hv, out)
454
+ };
455
+ }
456
+ }
457
+
458
+ // ── Rate limiting ────────────────────────────────────────────
459
+ let mut rate_limit: u32 = 0;
460
+ let mut rate_remaining: u32 = 0;
461
+ let mut rate_reset_ms: u64 = 0;
462
+
463
+ if rate_active {
464
+ let limiter = self.rate_limiter.as_ref().unwrap();
465
+ let now = rate_now_ms();
466
+ let key = resolved_ip.rate_key(limiter.seed());
467
+ let outcome = limiter.check_key(key, now);
468
+
469
+ rate_limit = limiter.limit();
470
+ rate_remaining = outcome.remaining;
471
+ rate_reset_ms = outcome.reset_ms;
472
+
473
+ if !outcome.allowed {
474
+ flags |= FLAG_RATE_LIMITED;
475
+ let retry_after_ms = outcome.reset_ms.saturating_sub(now);
476
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
477
+ let hv = compute_header_variant(cors_ok, false, true, true, true);
478
+ return terminal_rate_limited(flags, hv, rate_limit, rate_remaining, rate_reset_ms, retry_after_ms, out);
479
+ }
480
+ }
481
+
482
+ // ── Body size guard ──────────────────────────────────────────
483
+ if self.guard_enabled && body_bytes.len() > self.max_body_bytes {
484
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
485
+ let hv = compute_header_variant(cors_ok, false, rate_active, false, true);
486
+ return terminal_body_too_large(flags, hv, rate_limit, rate_remaining, rate_reset_ms, out);
487
+ }
488
+
489
+ // ── JSON body validation & schema ────────────────────────────
490
+ let has_body = !body_bytes.is_empty();
491
+
492
+ let enforce_json = if self.schema.is_some() {
493
+ mk.may_have_body() || has_body
494
+ } else {
495
+ self.require_json_body && (mk.may_have_body() || has_body)
496
+ };
497
+
498
+ if enforce_json {
499
+ if body_bytes.is_empty() {
500
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
501
+ let hv = compute_header_variant(cors_ok, false, rate_active, false, true);
502
+ return terminal_invalid_json(flags, hv, rate_limit, rate_remaining, rate_reset_ms, out);
503
+ }
504
+
505
+ let doc: serde_json::Value = match sonic_rs::from_slice(body_bytes) {
506
+ Ok(d) => d,
507
+ Err(_) => {
508
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
509
+ let hv = compute_header_variant(cors_ok, false, rate_active, false, true);
510
+ return terminal_invalid_json(flags, hv, rate_limit, rate_remaining, rate_reset_ms, out);
511
+ }
512
+ };
513
+
514
+ flags |= FLAG_BODY_VALID_JSON;
515
+
516
+ if let Some(validator) = self.schema.as_ref() {
517
+ if validator.is_valid(&doc) {
518
+ flags |= FLAG_SCHEMA_VALID;
519
+ } else {
520
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
521
+ let hv = compute_header_variant(cors_ok, false, rate_active, false, true);
522
+ return terminal_schema_validation(flags, hv, rate_limit, rate_remaining, rate_reset_ms, out);
523
+ }
524
+ }
525
+ }
526
+
527
+ // ── Parse cookies into output ────────────────────────────────
528
+ let mut data_pos = OUT_DATA_START;
529
+ let mut truncated = false;
530
+
531
+ let cookies_json_len: u32 = if self.parse_cookies && headers.has_cookie() {
532
+ if let Some(cookie_val) = headers.cookie() {
533
+ if cookie_val.len() <= self.limits.max_cookie_bytes {
534
+ match crate::json_ser::cookie_json_into_slice(
535
+ cookie_val,
536
+ &mut out[data_pos..],
537
+ self.limits.max_pairs,
538
+ ) {
539
+ Ok(written) => {
540
+ if written > 2 { flags |= crate::output::FLAG_HAS_COOKIES; }
541
+ written as u32
542
+ }
543
+ Err(_) => {
544
+ // Output buffer too small: never drop silently.
545
+ truncated = true;
546
+ 0
547
+ }
548
+ }
549
+ } else { 0 }
550
+ } else { 0 }
551
+ } else { 0 };
552
+
553
+ let cookies_start = data_pos;
554
+ data_pos += cookies_json_len as usize;
555
+
556
+ // ── Parse query into output ──────────────────────────────────
557
+ let query_json_len: u32 = if self.parse_query {
558
+ let raw_query = crate::proxy::extract_query(url_bytes);
559
+ if raw_query.len() > self.limits.max_query_bytes {
560
+ return terminal_simple(out, 1, crate::output::ERR_CODE_BAD_REQUEST, 414);
561
+ }
562
+ if !raw_query.is_empty() && data_pos < out.len() {
563
+ match crate::query_parser::query_parse_packed_vec(raw_query) {
564
+ Ok(packed) => {
565
+ match crate::json_ser::packed_pairs_to_json_into_slice(
566
+ &packed,
567
+ &mut out[data_pos..],
568
+ self.limits.max_pairs,
569
+ ) {
570
+ Ok(written) => {
571
+ if written > 2 { flags |= crate::output::FLAG_HAS_QUERY; }
572
+ written as u32
573
+ }
574
+ Err(_) => {
575
+ // Output buffer too small: never drop silently.
576
+ truncated = true;
577
+ 0
578
+ }
579
+ }
580
+ }
581
+ Err(_) => return terminal_simple(out, 1, crate::output::ERR_CODE_BAD_REQUEST, 400),
582
+ }
583
+ } else { 0 }
584
+ } else { 0 };
585
+
586
+ let query_start = data_pos;
587
+ data_pos += query_json_len as usize;
588
+
589
+ // ── Optional metadata JSON ───────────────────────────────────
590
+ let body_json_len = if self.emit_metadata_json {
591
+ let path = crate::proxy::extract_path(url_bytes);
592
+ crate::json_ser::write_full_body_json(
593
+ out, data_pos, rid_bytes, path,
594
+ cookies_start, cookies_json_len as usize,
595
+ query_start, query_json_len as usize,
596
+ )
597
+ } else { 0 };
598
+
599
+ // Any serialization shortfall (cookie/query envelope overflow or the
600
+ // metadata envelope) is surfaced via FLAG_BODY_TRUNCATED so the consumer
601
+ // never mistakes truncated data for complete data.
602
+ if truncated || (self.emit_metadata_json && body_json_len == 0) {
603
+ flags |= FLAG_BODY_TRUNCATED;
604
+ }
605
+
606
+ // ── Final output ─────────────────────────────────────────────
607
+ let cors_ok = (flags & FLAG_CORS_ALLOWED) != 0;
608
+ let hv = compute_header_variant(cors_ok, false, rate_active, false, true);
609
+
610
+ Ok(write_output_header(
611
+ out, 0, crate::output::ERR_CODE_NONE, 200, flags,
612
+ rate_limit, rate_remaining, rate_reset_ms, 0,
613
+ cookies_json_len, query_json_len, hv, body_json_len as u32,
614
+ ))
615
+ }
616
+ }
@@ -0,0 +1,89 @@
1
+ // rust/ingress_constants.rs — NAPI exports for ingress binary layout constants
2
+ // This is the SINGLE SOURCE OF TRUTH for the ingress output buffer format.
3
+ // TypeScript code reads these values via the native addon instead of duplicating them.
4
+
5
+ use napi_derive::napi;
6
+
7
+ // ── Output buffer layout ───────────────────────────────────
8
+ #[napi]
9
+ pub const INGRESS_OUT_VERDICT: u32 = 0;
10
+ #[napi]
11
+ pub const INGRESS_OUT_ERROR_CODE: u32 = 1;
12
+ #[napi]
13
+ pub const INGRESS_OUT_STATUS: u32 = 2;
14
+ #[napi]
15
+ pub const INGRESS_OUT_FLAGS: u32 = 4;
16
+ #[napi]
17
+ pub const INGRESS_OUT_RATE_LIMIT: u32 = 8;
18
+ #[napi]
19
+ pub const INGRESS_OUT_RATE_REMAINING: u32 = 12;
20
+ #[napi]
21
+ pub const INGRESS_OUT_RATE_RESET: u32 = 16;
22
+ #[napi]
23
+ pub const INGRESS_OUT_RETRY_AFTER: u32 = 24;
24
+ #[napi]
25
+ pub const INGRESS_OUT_COOKIES_JSON_LEN: u32 = 32;
26
+ #[napi]
27
+ pub const INGRESS_OUT_QUERY_JSON_LEN: u32 = 36;
28
+ #[napi]
29
+ pub const INGRESS_OUT_HEADER_VARIANT: u32 = 40;
30
+ #[napi]
31
+ pub const INGRESS_OUT_BODY_JSON_LEN: u32 = 44;
32
+ #[napi]
33
+ pub const INGRESS_OUT_DATA_START: u32 = 48;
34
+
35
+ // ── Flags ──────────────────────────────────────────────────
36
+ #[napi]
37
+ pub const INGRESS_FLAG_HAS_COOKIES: u32 = 1 << 0;
38
+ #[napi]
39
+ pub const INGRESS_FLAG_HAS_QUERY: u32 = 1 << 1;
40
+ #[napi]
41
+ pub const INGRESS_FLAG_BODY_VALID_JSON: u32 = 1 << 2;
42
+ #[napi]
43
+ pub const INGRESS_FLAG_SCHEMA_VALID: u32 = 1 << 3;
44
+ #[napi]
45
+ pub const INGRESS_FLAG_CORS_ALLOWED: u32 = 1 << 4;
46
+ #[napi]
47
+ pub const INGRESS_FLAG_IS_PREFLIGHT: u32 = 1 << 5;
48
+ #[napi]
49
+ pub const INGRESS_FLAG_RATE_LIMITED: u32 = 1 << 6;
50
+ #[napi]
51
+ pub const INGRESS_FLAG_HTTPS: u32 = 1 << 7;
52
+ #[napi]
53
+ pub const INGRESS_FLAG_TRUSTED_PROXY: u32 = 1 << 8;
54
+ #[napi]
55
+ pub const INGRESS_FLAG_BODY_TRUNCATED: u32 = 1 << 9;
56
+
57
+ // ── Header variant bits ────────────────────────────────────
58
+ #[napi]
59
+ pub const INGRESS_HV_JSON: u32 = 1 << 0;
60
+ #[napi]
61
+ pub const INGRESS_HV_CORS_SIMPLE: u32 = 1 << 1;
62
+ #[napi]
63
+ pub const INGRESS_HV_CORS_PREFLIGHT: u32 = 1 << 2;
64
+ #[napi]
65
+ pub const INGRESS_HV_RATE_ACTIVE: u32 = 1 << 3;
66
+ #[napi]
67
+ pub const INGRESS_HV_RATE_LIMITED: u32 = 1 << 4;
68
+ #[napi]
69
+ pub const INGRESS_HV_COUNT: u32 = 32;
70
+
71
+ // ── Error codes ────────────────────────────────────────────
72
+ #[napi]
73
+ pub const INGRESS_ERR_NONE: u32 = 0;
74
+ #[napi]
75
+ pub const INGRESS_ERR_CORS_PREFLIGHT: u32 = 1;
76
+ #[napi]
77
+ pub const INGRESS_ERR_RATE_LIMITED: u32 = 2;
78
+ #[napi]
79
+ pub const INGRESS_ERR_BODY_TOO_LARGE: u32 = 3;
80
+ #[napi]
81
+ pub const INGRESS_ERR_INVALID_JSON: u32 = 4;
82
+ #[napi]
83
+ pub const INGRESS_ERR_SCHEMA_VALIDATION: u32 = 5;
84
+ #[napi]
85
+ pub const INGRESS_ERR_BAD_REQUEST: u32 = 6;
86
+ #[napi]
87
+ pub const INGRESS_ERR_REQUEST_TOO_LARGE: u32 = 7;
88
+ #[napi]
89
+ pub const INGRESS_ERR_INTERNAL: u32 = 8;