castrum 0.9.3 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/Cargo.lock +2242 -0
  2. package/Cargo.toml +155 -0
  3. package/README.md +14 -6
  4. package/build.rs +5 -0
  5. package/castrum.darwin-arm64.node +0 -0
  6. package/castrum.darwin-x64.node +0 -0
  7. package/castrum.linux-arm64-gnu.node +0 -0
  8. package/castrum.linux-arm64-musl.node +0 -0
  9. package/castrum.linux-x64-gnu.node +0 -0
  10. package/castrum.linux-x64-musl.node +0 -0
  11. package/castrum.linux-x64-v3-gnu.node +0 -0
  12. package/castrum.win32-x64-msvc.node +0 -0
  13. package/dist/index.d.ts +1508 -1291
  14. package/dist/index.js +3391 -2069
  15. package/docs/API.md +2 -1
  16. package/docs/ARCHITECTURE.md +3 -2
  17. package/docs/CASE_STUDY.md +5 -3
  18. package/docs/ENVIRONMENT.md +25 -2
  19. package/docs/FFI_BUN_GUIDE.md +242 -14
  20. package/docs/HOW_TO_ADD_AN_OP.md +10 -0
  21. package/docs/REPO_MAP.md +17 -6
  22. package/docs/RND-CONCURRENCY.md +772 -0
  23. package/docs/bun-builtins-decision-matrix.md +4 -1
  24. package/index.ts +24 -25
  25. package/package.json +173 -156
  26. package/rust/crypto/aead.rs +378 -0
  27. package/rust/crypto/argon2.rs +265 -0
  28. package/rust/crypto/base64.rs +420 -0
  29. package/rust/crypto/bcrypt.rs +89 -0
  30. package/rust/crypto/cookie_sign.rs +169 -0
  31. package/rust/crypto/csrf.rs +106 -0
  32. package/rust/crypto/ed25519.rs +215 -0
  33. package/rust/crypto/hashing.rs +140 -0
  34. package/rust/crypto/hmac_sha256.rs +88 -0
  35. package/rust/crypto/jwt/api.rs +286 -0
  36. package/rust/crypto/jwt/mod.rs +22 -0
  37. package/rust/crypto/jwt/tests.rs +312 -0
  38. package/rust/crypto/jwt/token.rs +360 -0
  39. package/rust/crypto/mod.rs +27 -0
  40. package/rust/crypto/pbkdf2.rs +105 -0
  41. package/rust/crypto/random_token.rs +85 -0
  42. package/rust/crypto/session.rs +222 -0
  43. package/rust/ffi/crypto.rs +708 -0
  44. package/rust/ffi/hashing.rs +285 -0
  45. package/rust/ffi/http.rs +752 -0
  46. package/rust/ffi/ingress.rs +303 -0
  47. package/rust/ffi/json.rs +517 -0
  48. package/rust/ffi/jwt.rs +419 -0
  49. package/rust/ffi/metrics.rs +373 -0
  50. package/rust/ffi/mod.rs +69 -0
  51. package/rust/ffi/payload.rs +275 -0
  52. package/rust/ffi/probe.rs +60 -0
  53. package/rust/ffi/rate_limit.rs +77 -0
  54. package/rust/ffi/route.rs +85 -0
  55. package/rust/ffi/task.rs +172 -0
  56. package/rust/ffi/tests.rs +2333 -0
  57. package/rust/ffi/util.rs +152 -0
  58. package/rust/ffi/validators.rs +123 -0
  59. package/rust/http/accept.rs +606 -0
  60. package/rust/http/cookie_parser.rs +140 -0
  61. package/rust/http/etag.rs +289 -0
  62. package/rust/http/form.rs +136 -0
  63. package/rust/http/headers.rs +267 -0
  64. package/rust/http/http_date.rs +286 -0
  65. package/rust/http/http_parser.rs +252 -0
  66. package/rust/http/media_type.rs +355 -0
  67. package/rust/http/method.rs +200 -0
  68. package/rust/http/mime_lookup.rs +130 -0
  69. package/rust/http/mod.rs +32 -0
  70. package/rust/http/multipart.rs +685 -0
  71. package/rust/http/query_parser.rs +211 -0
  72. package/rust/http/url_codec.rs +296 -0
  73. package/rust/http/url_join.rs +324 -0
  74. package/rust/ingress/api.rs +355 -0
  75. package/rust/ingress/cors.rs +403 -0
  76. package/rust/ingress/ingress_constants.rs +209 -0
  77. package/rust/ingress/ip_trust.rs +312 -0
  78. package/rust/ingress/mod.rs +48 -0
  79. package/rust/ingress/native_route.rs +770 -0
  80. package/rust/ingress/options.rs +176 -0
  81. package/rust/ingress/output.rs +406 -0
  82. package/rust/ingress/packed.rs +191 -0
  83. package/rust/ingress/pipeline.rs +534 -0
  84. package/rust/ingress/proxy.rs +138 -0
  85. package/rust/ingress/rate_limit.rs +538 -0
  86. package/rust/ingress/terminal.rs +114 -0
  87. package/rust/ingress/tests.rs +567 -0
  88. package/rust/ingress/time.rs +70 -0
  89. package/rust/json/fast_schema/capture.rs +336 -0
  90. package/rust/json/fast_schema/compile.rs +556 -0
  91. package/rust/json/fast_schema/cursor.rs +368 -0
  92. package/rust/json/fast_schema/email.rs +282 -0
  93. package/rust/json/fast_schema/errors.rs +247 -0
  94. package/rust/json/fast_schema/mod.rs +57 -0
  95. package/rust/json/fast_schema/tests.rs +1143 -0
  96. package/rust/json/fast_schema/types.rs +140 -0
  97. package/rust/json/fast_schema/validate.rs +931 -0
  98. package/rust/json/json_ops.rs +121 -0
  99. package/rust/json/json_schema.rs +837 -0
  100. package/rust/json/json_ser.rs +883 -0
  101. package/rust/json/mod.rs +19 -0
  102. package/rust/json/napi_marshal.rs +318 -0
  103. package/rust/json/patch/api.rs +60 -0
  104. package/rust/json/patch/engine.rs +153 -0
  105. package/rust/json/patch/mod.rs +47 -0
  106. package/rust/json/patch/ops.rs +270 -0
  107. package/rust/json/patch/pointer.rs +49 -0
  108. package/rust/json/patch/tests.rs +499 -0
  109. package/rust/lib.rs +73 -0
  110. package/rust/metrics/api.rs +148 -0
  111. package/rust/metrics/mod.rs +21 -0
  112. package/rust/metrics/registry.rs +961 -0
  113. package/rust/panic_safety.rs +40 -0
  114. package/rust/payload/compress.rs +512 -0
  115. package/rust/payload/mod.rs +13 -0
  116. package/rust/payload/sse.rs +214 -0
  117. package/rust/payload/template.rs +253 -0
  118. package/rust/payload/websocket.rs +69 -0
  119. package/rust/payload/ws_frames.rs +354 -0
  120. package/rust/proptest_suite.rs +139 -0
  121. package/rust/selection.rs +95 -0
  122. package/rust/src/bin/native-bench.rs +495 -0
  123. package/rust/task/completion.rs +119 -0
  124. package/rust/task/mod.rs +65 -0
  125. package/rust/task/ops.rs +503 -0
  126. package/rust/task/runtime.rs +220 -0
  127. package/rust/task/tests.rs +231 -0
  128. package/rust/test_support.rs +71 -0
  129. package/rust/util/batch/api.rs +266 -0
  130. package/rust/util/batch/core.rs +473 -0
  131. package/rust/util/batch/mod.rs +23 -0
  132. package/rust/util/batch/tests.rs +445 -0
  133. package/rust/util/batch_core.rs +184 -0
  134. package/rust/util/bytes.rs +594 -0
  135. package/rust/util/mod.rs +34 -0
  136. package/rust/util/packed.rs +473 -0
  137. package/rust/util/text.rs +130 -0
  138. package/rust/util/threadpool.rs +128 -0
  139. package/rust/util/validation.rs +419 -0
  140. package/rust-toolchain.toml +17 -0
  141. package/scripts/postinstall.mjs +199 -0
  142. package/src/ingress/constants.ts +1 -1
  143. package/src/ingress/context.ts +2 -2
  144. package/src/ingress/decode/baked-result.ts +13 -13
  145. package/src/ingress/decode/fast-result.ts +17 -17
  146. package/src/ingress/errors.ts +7 -7
  147. package/src/ingress/headers/baked-templates.ts +1 -1
  148. package/src/ingress/headers/fast-templates.ts +3 -3
  149. package/src/ingress/health.ts +1 -2
  150. package/src/ingress/index.ts +1 -1
  151. package/src/ingress/native-route.ts +9 -5
  152. package/src/ingress/options.ts +1 -1
  153. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  154. package/src/ingress/packing/route-wire.ts +1 -5
  155. package/src/ingress/response/error-bodies.ts +5 -5
  156. package/src/ingress/response/terminal.ts +1 -1
  157. package/src/ingress/routes/echo.ts +4 -4
  158. package/src/ingress/routes/fallback.ts +2 -2
  159. package/src/ingress/routes/head.ts +2 -2
  160. package/src/ingress/routes/native.ts +2 -1
  161. package/src/ingress/routes/options.ts +1 -1
  162. package/src/ingress/routes/read.ts +2 -2
  163. package/src/ingress/server.ts +10 -6
  164. package/src/ingress/status.ts +7 -7
  165. package/src/integration/batch.ts +1 -1
  166. package/src/integration/index.ts +9 -9
  167. package/src/integration/pipeline.ts +6 -6
  168. package/src/integration/websocket.ts +2 -2
  169. package/src/loader/index.ts +1 -5
  170. package/src/loader/ops.ts +1 -1
  171. package/src/native/ffi/build/codecs.ts +130 -12
  172. package/src/native/ffi/build/compress.ts +22 -10
  173. package/src/native/ffi/build/instances.ts +46 -4
  174. package/src/native/ffi/build/metrics.ts +247 -0
  175. package/src/native/ffi/build/parse.ts +59 -23
  176. package/src/native/ffi/build/task.ts +79 -0
  177. package/src/native/ffi/build/util.ts +32 -1
  178. package/src/native/ffi/build.ts +4 -0
  179. package/src/native/ffi/selftest.ts +12 -3
  180. package/src/native/ffi/types.ts +151 -9
  181. package/src/native/ffi.ts +160 -15
  182. package/src/native/index.ts +1 -1
  183. package/src/native/loader.ts +55 -5
  184. package/src/native/types/instances.ts +34 -0
  185. package/src/native/types.ts +11 -0
  186. package/src/runtime/codec.ts +53 -9
  187. package/src/runtime/types.ts +7 -0
  188. package/src/rust-ffi/batch/build.ts +16 -9
  189. package/src/rust-ffi/index.ts +7 -6
  190. package/src/rust-ffi/options.ts +1 -3
  191. package/src/rust-ffi/proven.ts +3 -3
  192. package/src/rust-ffi/scalar/crypto.ts +1 -1
  193. package/src/rust-ffi/scalar/factories.ts +73 -0
  194. package/src/rust-ffi/scalar/hashing.ts +1 -1
  195. package/src/rust-ffi/scalar/http.ts +71 -27
  196. package/src/rust-ffi/scalar/index.ts +2 -0
  197. package/src/rust-ffi/scalar/interface.ts +20 -0
  198. package/src/rust-ffi/scalar/payload.ts +11 -3
  199. package/src/rust-ffi/scalar/validation.ts +121 -0
  200. package/src/rust-ffi/text.ts +25 -3
  201. package/src/selection.ts +1 -1
  202. package/src/shared/bytes.ts +24 -4
  203. package/src/shared/codec.ts +18 -3
  204. package/src/task/index.ts +24 -0
  205. package/src/task/op.ts +171 -0
  206. package/src/task/runtime.ts +568 -0
@@ -0,0 +1,567 @@
1
+ use std::sync::Arc;
2
+
3
+ use super::*;
4
+ use crate::ingress::cors::{CorsEngine, CorsOptions};
5
+ use crate::ingress::output::{
6
+ ERR_CODE_BAD_REQUEST, ERR_CODE_BODY_TOO_LARGE, ERR_CODE_INVALID_JSON, ERR_CODE_NONE,
7
+ ERR_CODE_RATE_LIMITED, ERR_CODE_REQUEST_TOO_LARGE, ERR_CODE_SCHEMA_VALIDATION,
8
+ FLAG_CORS_ALLOWED, FLAG_HAS_COOKIES, FLAG_HAS_QUERY, FLAG_HTTPS, FLAG_SCHEMA_VALID,
9
+ HV_CORS_SIMPLE, HV_JSON, OUT_COOKIES_JSON_LEN, OUT_DATA_START, OUT_ERROR_CODE, OUT_FLAGS,
10
+ OUT_HEADER_VARIANT, OUT_QUERY_JSON_LEN, OUT_STATUS, OUT_VERDICT,
11
+ };
12
+ use crate::ingress::pipeline::IngressSchema;
13
+ use crate::ingress::rate_limit::{KeyedRateLimiter, RateLimiterState};
14
+
15
+ /// Build a packed input frame: `[method]` then u32le-length-prefixed
16
+ /// url/ip/rid sections, then a u32le-length-prefixed headers section.
17
+ fn packed_input(method: u8, url: &[u8], ip: &[u8], rid: &[u8]) -> Vec<u8> {
18
+ let mut out = Vec::new();
19
+ out.push(method);
20
+ for section in [url, ip, rid] {
21
+ out.extend_from_slice(&(section.len() as u32).to_le_bytes());
22
+ out.extend_from_slice(section);
23
+ }
24
+ // Headers section: empty for these tests.
25
+ out.extend_from_slice(&0u32.to_le_bytes());
26
+ out
27
+ }
28
+
29
+ fn base_inner() -> IngressInner {
30
+ IngressInner {
31
+ https_fixed: None,
32
+ max_body_bytes: 1_048_576,
33
+ proxy_trust: crate::ingress::ip_trust::ProxyTrustMode::None,
34
+ parse_cookies: false,
35
+ parse_query: false,
36
+ require_json_body: false,
37
+ guard_enabled: true,
38
+ emit_metadata_json: false,
39
+ cors_enabled: false,
40
+ cors: crate::ingress::cors::CorsEngine::disabled(),
41
+ rate: RateLimiterState::Disabled,
42
+ schema: None,
43
+ limits: Limits::default(),
44
+ }
45
+ }
46
+
47
+ #[test]
48
+ fn handle_packed_simple_get_ok() {
49
+ let inner = base_inner();
50
+ let input = packed_input(0, b"/api/users", b"127.0.0.1", b"rid-1");
51
+ let mut out = vec![0u8; 512];
52
+ let written = inner.handle_packed(&input, b"", &mut out).unwrap();
53
+
54
+ assert_eq!(out[OUT_VERDICT], 0);
55
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_NONE);
56
+ assert_eq!(
57
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
58
+ 200
59
+ );
60
+ assert_eq!(out[OUT_HEADER_VARIANT], HV_JSON);
61
+ assert_eq!(written, OUT_DATA_START);
62
+ }
63
+
64
+ #[test]
65
+ fn handle_packed_body_too_large_413() {
66
+ let inner = IngressInner {
67
+ max_body_bytes: 4,
68
+ ..base_inner()
69
+ };
70
+ let input = packed_input(2, b"/api", b"127.0.0.1", b"rid");
71
+ let mut out = vec![0u8; 512];
72
+ inner.handle_packed(&input, b"12345", &mut out).unwrap();
73
+
74
+ assert_eq!(out[OUT_VERDICT], 1);
75
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_BODY_TOO_LARGE);
76
+ assert_eq!(
77
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
78
+ 413
79
+ );
80
+ }
81
+
82
+ #[test]
83
+ fn handle_packed_invalid_json_400() {
84
+ let inner = IngressInner {
85
+ require_json_body: true,
86
+ ..base_inner()
87
+ };
88
+ let input = packed_input(2, b"/api", b"127.0.0.1", b"rid");
89
+ let mut out = vec![0u8; 512];
90
+ inner.handle_packed(&input, b"not json", &mut out).unwrap();
91
+
92
+ assert_eq!(out[OUT_VERDICT], 1);
93
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_INVALID_JSON);
94
+ assert_eq!(
95
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
96
+ 400
97
+ );
98
+ }
99
+
100
+ #[test]
101
+ fn handle_packed_url_too_long_414() {
102
+ let inner = IngressInner {
103
+ limits: Limits {
104
+ max_url_bytes: 8,
105
+ ..Limits::default()
106
+ },
107
+ ..base_inner()
108
+ };
109
+ let input = packed_input(0, b"/a/very/long/url/path", b"127.0.0.1", b"rid");
110
+ let mut out = vec![0u8; 512];
111
+ inner.handle_packed(&input, b"", &mut out).unwrap();
112
+
113
+ assert_eq!(out[OUT_VERDICT], 1);
114
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_BAD_REQUEST);
115
+ assert_eq!(
116
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
117
+ 414
118
+ );
119
+ }
120
+
121
+ // ── handle_request_full_sync_into / full_sync_into ─────────────
122
+
123
+ /// Build a minimal `IngressOptions` for unit tests (no JS runtime needed).
124
+ fn base_options() -> IngressOptions {
125
+ IngressOptions {
126
+ trust_proxy: None,
127
+ trusted_proxies: None,
128
+ parse_cookies: Some(true),
129
+ parse_query: Some(true),
130
+ require_json_body: None,
131
+ schema: None,
132
+ cors: None,
133
+ rate_limit: None,
134
+ https: Some(true),
135
+ max_body_bytes: None,
136
+ enable_body_size_guard: Some(true),
137
+ emit_metadata_json: Some(true),
138
+ limits: None,
139
+ }
140
+ }
141
+
142
+ #[test]
143
+ fn build_packed_input_sync_layout_matches_manual_frame() {
144
+ let headers: Vec<(String, String)> = vec![
145
+ (String::from("cookie"), String::from("a=1;b=2")),
146
+ (String::from("origin"), String::from("https://x")),
147
+ ];
148
+ let packed = build_packed_input_sync(2, b"/api", b"1.2.3.4", b"rid", &headers);
149
+
150
+ // Manual equivalent: [method] then u32le-length-prefixed url/ip/rid
151
+ // sections, then a u32le-length-prefixed headers section containing a
152
+ // u16le count followed by (u16le name-len, name, u32le value-len, value).
153
+ let mut manual = Vec::new();
154
+ manual.push(2u8);
155
+ for section in [b"/api".as_slice(), b"1.2.3.4".as_slice(), b"rid".as_slice()] {
156
+ manual.extend_from_slice(&(section.len() as u32).to_le_bytes());
157
+ manual.extend_from_slice(section);
158
+ }
159
+ let header_pairs_len: usize = headers.iter().map(|(n, v)| 2 + n.len() + 4 + v.len()).sum();
160
+ manual.extend_from_slice(&((2 + header_pairs_len) as u32).to_le_bytes());
161
+ manual.extend_from_slice(&(headers.len() as u16).to_le_bytes());
162
+ for (n, v) in &headers {
163
+ manual.extend_from_slice(&(n.len() as u16).to_le_bytes());
164
+ manual.extend_from_slice(n.as_bytes());
165
+ manual.extend_from_slice(&(v.len() as u32).to_le_bytes());
166
+ manual.extend_from_slice(v.as_bytes());
167
+ }
168
+
169
+ assert_eq!(packed, manual);
170
+ }
171
+
172
+ #[test]
173
+ fn full_sync_into_matches_handle_packed_reference() {
174
+ let ingress = Ingress::new(base_options()).unwrap();
175
+
176
+ let headers: Vec<Vec<String>> = vec![
177
+ vec![String::from("cookie"), String::from("a=1;b=2")],
178
+ vec![String::from("x-forwarded-for"), String::from("9.9.9.9")],
179
+ ];
180
+
181
+ let mut out = vec![0u8; 131072];
182
+ let written = ingress
183
+ .full_sync_into(
184
+ 0,
185
+ "/api/users",
186
+ "127.0.0.1",
187
+ "rid-1",
188
+ headers,
189
+ b"",
190
+ &mut out,
191
+ )
192
+ .expect("full_sync_into should succeed");
193
+
194
+ // Reference: handle_packed on the equivalent manually-built frame,
195
+ // using an inner that mirrors `base_options()` (parse cookies/query,
196
+ // emit metadata, https fixed).
197
+ let inner = IngressInner {
198
+ https_fixed: Some(true),
199
+ parse_cookies: true,
200
+ parse_query: true,
201
+ emit_metadata_json: true,
202
+ ..base_inner()
203
+ };
204
+ let mut ref_out = vec![0u8; 131072];
205
+ let header_pairs = vec![
206
+ (String::from("cookie"), String::from("a=1;b=2")),
207
+ (String::from("x-forwarded-for"), String::from("9.9.9.9")),
208
+ ];
209
+ let ref_packed =
210
+ build_packed_input_sync(0, b"/api/users", b"127.0.0.1", b"rid-1", &header_pairs);
211
+ let ref_written = inner.handle_packed(&ref_packed, b"", &mut ref_out).unwrap();
212
+
213
+ assert_eq!(written, ref_written);
214
+ assert_eq!(&out[..written], &ref_out[..ref_written]);
215
+ // sanity: 200 ok
216
+ assert_eq!(out[OUT_VERDICT], 0);
217
+ assert_eq!(
218
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]]),
219
+ 200
220
+ );
221
+ }
222
+
223
+ #[test]
224
+ fn full_sync_into_rejects_tiny_output_buffer() {
225
+ let ingress = Ingress::new(base_options()).unwrap();
226
+ let mut tiny = vec![0u8; OUT_DATA_START - 1];
227
+ let err = ingress.full_sync_into(0, "/x", "1.1.1.1", "rid", vec![], b"", &mut tiny);
228
+ assert!(err.is_err());
229
+ }
230
+
231
+ #[test]
232
+ fn clamp_output_size_bounds() {
233
+ // Regression: a misconfigured huge `outputBufferSize` (e.g. u32::MAX ≈ 4 GiB)
234
+ // must be clamped to MAX_OUTPUT_BUFFER_SIZE instead of OOM-ing the process.
235
+ assert_eq!(clamp_output_size(None), 262_144); // default
236
+ assert_eq!(clamp_output_size(Some(0)), OUT_DATA_START); // floor
237
+ assert_eq!(clamp_output_size(Some(4096)), 4096); // passthrough
238
+ // u32::MAX (≈ 4 GiB) is capped to MAX_OUTPUT_BUFFER_SIZE (64 MiB = 67_108_864).
239
+ // (Literal avoids clippy 0.1.97's const-cast false positive.)
240
+ assert_eq!(clamp_output_size(Some(u32::MAX)), 67_108_864);
241
+ }
242
+
243
+ // ── Enabled-feature pipeline e2e (schema / rate limit / CORS / limits) ──
244
+
245
+ /// Build a packed input frame including a headers section:
246
+ /// `[method]` then u32le-length-prefixed url/ip/rid sections, then a
247
+ /// u32le-length-prefixed headers section
248
+ /// (`[u16 count] { [u16 name_len][name][u32 val_len][val] }`).
249
+ fn packed_input_with_headers(
250
+ method: u8,
251
+ url: &[u8],
252
+ ip: &[u8],
253
+ rid: &[u8],
254
+ headers: &[(&str, &str)],
255
+ ) -> Vec<u8> {
256
+ let mut out = Vec::new();
257
+ out.push(method);
258
+ for section in [url, ip, rid] {
259
+ out.extend_from_slice(&(section.len() as u32).to_le_bytes());
260
+ out.extend_from_slice(section);
261
+ }
262
+ let header_pairs_len: usize = headers.iter().map(|(n, v)| 2 + n.len() + 4 + v.len()).sum();
263
+ out.extend_from_slice(&((2 + header_pairs_len) as u32).to_le_bytes());
264
+ out.extend_from_slice(&(headers.len() as u16).to_le_bytes());
265
+ for (n, v) in headers {
266
+ out.extend_from_slice(&(n.len() as u16).to_le_bytes());
267
+ out.extend_from_slice(n.as_bytes());
268
+ out.extend_from_slice(&(v.len() as u32).to_le_bytes());
269
+ out.extend_from_slice(v.as_bytes());
270
+ }
271
+ out
272
+ }
273
+
274
+ fn flags_at(out: &[u8]) -> u32 {
275
+ u32::from_le_bytes([
276
+ out[OUT_FLAGS],
277
+ out[OUT_FLAGS + 1],
278
+ out[OUT_FLAGS + 2],
279
+ out[OUT_FLAGS + 3],
280
+ ])
281
+ }
282
+
283
+ fn status_at(out: &[u8]) -> u16 {
284
+ u16::from_le_bytes([out[OUT_STATUS], out[OUT_STATUS + 1]])
285
+ }
286
+
287
+ #[test]
288
+ fn schema_valid_200_and_invalid_422() {
289
+ let schema = IngressSchema::compile(&serde_json::json!({
290
+ "type": "object",
291
+ "required": ["id"],
292
+ "properties": { "id": { "type": "number" } },
293
+ }))
294
+ .expect("schema compiles");
295
+ let inner = IngressInner {
296
+ schema: Some(Arc::new(schema)),
297
+ ..base_inner()
298
+ };
299
+ let input = packed_input(2, b"/api", b"127.0.0.1", b"rid");
300
+
301
+ // Well-formed but schema-invalid body → 422 (distinct from 400 malformed).
302
+ let mut out = vec![0u8; 512];
303
+ inner
304
+ .handle_packed(&input, br#"{"id":"x"}"#, &mut out)
305
+ .unwrap();
306
+ assert_eq!(out[OUT_VERDICT], 1);
307
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_SCHEMA_VALIDATION);
308
+ assert_eq!(status_at(&out), 422);
309
+
310
+ // Valid body → 200 with FLAG_SCHEMA_VALID (exercises the zero-DOM fast
311
+ // path, since this schema uses only supported keywords).
312
+ let mut out = vec![0u8; 512];
313
+ inner
314
+ .handle_packed(&input, br#"{"id":1}"#, &mut out)
315
+ .unwrap();
316
+ assert_eq!(out[OUT_VERDICT], 0);
317
+ assert_eq!(status_at(&out), 200);
318
+ assert_ne!(flags_at(&out) & FLAG_SCHEMA_VALID, 0);
319
+ }
320
+
321
+ #[test]
322
+ fn rate_limit_429_after_limit() {
323
+ let inner = IngressInner {
324
+ rate: RateLimiterState::Enabled(Arc::new(KeyedRateLimiter::new(1, 60_000, None))),
325
+ ..base_inner()
326
+ };
327
+ let input = packed_input(0, b"/api", b"127.0.0.1", b"rid");
328
+ let mut out1 = vec![0u8; 512];
329
+ let mut out2 = vec![0u8; 512];
330
+ inner.handle_packed(&input, b"", &mut out1).unwrap();
331
+ inner.handle_packed(&input, b"", &mut out2).unwrap();
332
+
333
+ assert_eq!(out1[OUT_VERDICT], 0, "first request is allowed");
334
+ assert_eq!(status_at(&out1), 200);
335
+ assert_eq!(out2[OUT_VERDICT], 1, "second request is rate limited");
336
+ assert_eq!(out2[OUT_ERROR_CODE], ERR_CODE_RATE_LIMITED);
337
+ assert_eq!(status_at(&out2), 429);
338
+ }
339
+
340
+ fn cors_engine() -> CorsEngine {
341
+ CorsEngine::from_options(Some(CorsOptions {
342
+ allow_origin: Some(vec!["https://app.example.com".into()]),
343
+ allow_methods: None,
344
+ allow_headers: None,
345
+ allow_credentials: Some(false),
346
+ }))
347
+ .unwrap()
348
+ }
349
+
350
+ #[test]
351
+ fn cors_preflight_allowed_204_forbidden_403() {
352
+ let inner = IngressInner {
353
+ cors_enabled: true,
354
+ cors: cors_engine(),
355
+ ..base_inner()
356
+ };
357
+
358
+ // Allowed preflight: OPTIONS (6) with matching origin + ACRM.
359
+ let input = packed_input_with_headers(
360
+ 6,
361
+ b"/api",
362
+ b"127.0.0.1",
363
+ b"rid",
364
+ &[
365
+ ("origin", "https://app.example.com"),
366
+ ("access-control-request-method", "POST"),
367
+ ],
368
+ );
369
+ let mut out = vec![0u8; 512];
370
+ inner.handle_packed(&input, b"", &mut out).unwrap();
371
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_NONE);
372
+ assert_eq!(status_at(&out), 204);
373
+
374
+ // Forbidden preflight: non-matching origin.
375
+ let input = packed_input_with_headers(
376
+ 6,
377
+ b"/api",
378
+ b"127.0.0.1",
379
+ b"rid",
380
+ &[
381
+ ("origin", "https://evil.example.com"),
382
+ ("access-control-request-method", "POST"),
383
+ ],
384
+ );
385
+ let mut out = vec![0u8; 512];
386
+ inner.handle_packed(&input, b"", &mut out).unwrap();
387
+ assert_eq!(status_at(&out), 403);
388
+ }
389
+
390
+ #[test]
391
+ fn cors_simple_sets_flag_and_hv() {
392
+ let inner = IngressInner {
393
+ cors_enabled: true,
394
+ cors: cors_engine(),
395
+ ..base_inner()
396
+ };
397
+ let input = packed_input_with_headers(
398
+ 0,
399
+ b"/api",
400
+ b"127.0.0.1",
401
+ b"rid",
402
+ &[("origin", "https://app.example.com")],
403
+ );
404
+ let mut out = vec![0u8; 512];
405
+ inner.handle_packed(&input, b"", &mut out).unwrap();
406
+ assert_eq!(status_at(&out), 200);
407
+ assert_ne!(flags_at(&out) & FLAG_CORS_ALLOWED, 0);
408
+ assert_ne!(out[OUT_HEADER_VARIANT] & HV_CORS_SIMPLE, 0);
409
+ }
410
+
411
+ #[test]
412
+ fn too_many_headers_431() {
413
+ let inner = IngressInner {
414
+ limits: Limits {
415
+ max_headers: 1,
416
+ ..Limits::default()
417
+ },
418
+ ..base_inner()
419
+ };
420
+ let input =
421
+ packed_input_with_headers(0, b"/api", b"127.0.0.1", b"rid", &[("a", "1"), ("b", "2")]);
422
+ let mut out = vec![0u8; 512];
423
+ inner.handle_packed(&input, b"", &mut out).unwrap();
424
+ assert_eq!(out[OUT_VERDICT], 1);
425
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_REQUEST_TOO_LARGE);
426
+ assert_eq!(status_at(&out), 431);
427
+ }
428
+
429
+ #[test]
430
+ fn headers_section_over_bytes_431() {
431
+ let inner = IngressInner {
432
+ limits: Limits {
433
+ max_headers_bytes: 4,
434
+ ..Limits::default()
435
+ },
436
+ ..base_inner()
437
+ };
438
+ let input = packed_input_with_headers(0, b"/api", b"127.0.0.1", b"rid", &[("a", "123456")]);
439
+ let mut out = vec![0u8; 512];
440
+ inner.handle_packed(&input, b"", &mut out).unwrap();
441
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_REQUEST_TOO_LARGE);
442
+ assert_eq!(status_at(&out), 431);
443
+ }
444
+
445
+ #[test]
446
+ fn query_over_limit_414() {
447
+ let inner = IngressInner {
448
+ parse_query: true,
449
+ limits: Limits {
450
+ max_query_bytes: 4,
451
+ ..Limits::default()
452
+ },
453
+ ..base_inner()
454
+ };
455
+ let input = packed_input(0, b"/api?long=1234567890", b"127.0.0.1", b"rid");
456
+ let mut out = vec![0u8; 512];
457
+ inner.handle_packed(&input, b"", &mut out).unwrap();
458
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_BAD_REQUEST);
459
+ assert_eq!(status_at(&out), 414);
460
+ }
461
+
462
+ #[test]
463
+ fn https_fixed_sets_flag() {
464
+ let inner = IngressInner {
465
+ https_fixed: Some(true),
466
+ ..base_inner()
467
+ };
468
+ let input = packed_input(0, b"/api", b"127.0.0.1", b"rid");
469
+ let mut out = vec![0u8; 512];
470
+ inner.handle_packed(&input, b"", &mut out).unwrap();
471
+ assert_ne!(flags_at(&out) & FLAG_HTTPS, 0);
472
+ }
473
+
474
+ #[test]
475
+ fn cookies_and_query_json_written() {
476
+ let inner = IngressInner {
477
+ parse_cookies: true,
478
+ parse_query: true,
479
+ ..base_inner()
480
+ };
481
+ let input = packed_input_with_headers(
482
+ 0,
483
+ b"/api?page=2&q=hello%20world",
484
+ b"127.0.0.1",
485
+ b"rid",
486
+ &[("cookie", "sid=abc123; theme=dark")],
487
+ );
488
+ let mut out = vec![0u8; 4096];
489
+ inner.handle_packed(&input, b"", &mut out).unwrap();
490
+ assert_eq!(status_at(&out), 200);
491
+ assert_ne!(flags_at(&out) & FLAG_HAS_COOKIES, 0);
492
+ assert_ne!(flags_at(&out) & FLAG_HAS_QUERY, 0);
493
+
494
+ let cookies_len = u32::from_le_bytes([
495
+ out[OUT_COOKIES_JSON_LEN],
496
+ out[OUT_COOKIES_JSON_LEN + 1],
497
+ out[OUT_COOKIES_JSON_LEN + 2],
498
+ out[OUT_COOKIES_JSON_LEN + 3],
499
+ ]) as usize;
500
+ let query_len = u32::from_le_bytes([
501
+ out[OUT_QUERY_JSON_LEN],
502
+ out[OUT_QUERY_JSON_LEN + 1],
503
+ out[OUT_QUERY_JSON_LEN + 2],
504
+ out[OUT_QUERY_JSON_LEN + 3],
505
+ ]) as usize;
506
+ assert!(cookies_len > 2);
507
+ assert!(query_len > 2);
508
+
509
+ let cookies_json = &out[OUT_DATA_START..OUT_DATA_START + cookies_len];
510
+ let query_json = &out[OUT_DATA_START + cookies_len..OUT_DATA_START + cookies_len + query_len];
511
+ assert_eq!(cookies_json, br#"{"sid":"abc123","theme":"dark"}"#);
512
+ // Query keys in order; `%20` decoded to a space and JSON-escaped.
513
+ assert_eq!(query_json, br#"{"page":"2","q":"hello world"}"#);
514
+ }
515
+
516
+ #[test]
517
+ fn unicode_query_json_written() {
518
+ let inner = IngressInner {
519
+ parse_query: true,
520
+ ..base_inner()
521
+ };
522
+ let input = packed_input(
523
+ 0,
524
+ "/api?q=%E2%9C%93&e=%F0%9F%98%80".as_bytes(),
525
+ b"127.0.0.1",
526
+ b"rid",
527
+ );
528
+ let mut out = vec![0u8; 4096];
529
+ inner.handle_packed(&input, b"", &mut out).unwrap();
530
+ assert_eq!(status_at(&out), 200);
531
+
532
+ let query_len = u32::from_le_bytes([
533
+ out[OUT_QUERY_JSON_LEN],
534
+ out[OUT_QUERY_JSON_LEN + 1],
535
+ out[OUT_QUERY_JSON_LEN + 2],
536
+ out[OUT_QUERY_JSON_LEN + 3],
537
+ ]) as usize;
538
+ let query_json = &out[OUT_DATA_START..OUT_DATA_START + query_len];
539
+ // Percent-decoded UTF-8 (`✓`, `😀`) is preserved verbatim in the JSON.
540
+ let expected = "{\"q\":\"✓\",\"e\":\"😀\"}".as_bytes();
541
+ assert_eq!(query_json, expected);
542
+ }
543
+
544
+ #[test]
545
+ fn truncated_multibyte_query_escaped() {
546
+ let inner = IngressInner {
547
+ parse_query: true,
548
+ ..base_inner()
549
+ };
550
+ // `%E2%9C` is a truncated 3-byte sequence: `decodeURIComponent` throws, so
551
+ // the component is returned RAW ("%E2%9C") — the same thing the JS
552
+ // fallback does — and the request is NOT rejected. The JSON writer then
553
+ // sees plain ASCII and the length accounting is the raw length.
554
+ let input = packed_input(0, "/api?q=%E2%9C".as_bytes(), b"127.0.0.1", b"rid");
555
+ let mut out = vec![0u8; 4096];
556
+ inner.handle_packed(&input, b"", &mut out).unwrap();
557
+ assert_eq!(status_at(&out), 200);
558
+
559
+ let query_len = u32::from_le_bytes([
560
+ out[OUT_QUERY_JSON_LEN],
561
+ out[OUT_QUERY_JSON_LEN + 1],
562
+ out[OUT_QUERY_JSON_LEN + 2],
563
+ out[OUT_QUERY_JSON_LEN + 3],
564
+ ]) as usize;
565
+ let query_json = &out[OUT_DATA_START..OUT_DATA_START + query_len];
566
+ assert_eq!(query_json, br#"{"q":"%E2%9C"}"#);
567
+ }
@@ -0,0 +1,70 @@
1
+ // rust/ingress/time.rs — Monotonic + wall-clock helpers.
2
+ //
3
+ // The rate limiter needs a stable monotonic clock plus a one-time wall offset
4
+ // so `rate_now_ms` returns a timestamp consistent across the process. The
5
+ // `OnceLock` statics are initialized lazily on first use.
6
+
7
+ use std::sync::OnceLock;
8
+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
9
+
10
+ static START: OnceLock<Instant> = OnceLock::new();
11
+ static WALL_OFFSET_MS: OnceLock<i128> = OnceLock::new();
12
+
13
+ #[inline(always)]
14
+ pub(crate) fn monotonic_ms() -> u128 {
15
+ START.get_or_init(Instant::now).elapsed().as_millis()
16
+ }
17
+
18
+ #[inline(always)]
19
+ fn wall_now_ms() -> u64 {
20
+ SystemTime::now()
21
+ .duration_since(UNIX_EPOCH)
22
+ .map(|d| d.as_millis() as u64)
23
+ .unwrap_or(0)
24
+ }
25
+
26
+ #[inline(always)]
27
+ fn wall_offset_ms() -> i128 {
28
+ *WALL_OFFSET_MS.get_or_init(|| wall_now_ms() as i128 - monotonic_ms() as i128)
29
+ }
30
+
31
+ /// Current time in ms since the unix epoch, derived from the monotonic clock.
32
+ #[inline(always)]
33
+ pub(crate) fn rate_now_ms() -> u64 {
34
+ let v = monotonic_ms() as i128 + wall_offset_ms();
35
+ if v < 0 {
36
+ 0
37
+ } else {
38
+ v as u64
39
+ }
40
+ }
41
+
42
+ #[cfg(test)]
43
+ mod tests {
44
+ use super::*;
45
+
46
+ #[test]
47
+ fn rate_now_ms_is_sane_unix_time() {
48
+ let now = rate_now_ms();
49
+ // A plausible unix-epoch millis value: after ~2001, before ~year 3000.
50
+ assert!(now > 978_307_200_000, "rate_now_ms too small: {now}");
51
+ assert!(now < 32_000_000_000_000, "rate_now_ms too large: {now}");
52
+ }
53
+
54
+ #[test]
55
+ fn monotonic_ms_is_non_decreasing() {
56
+ let a = monotonic_ms();
57
+ let b = monotonic_ms();
58
+ assert!(b >= a, "monotonic clock went backwards");
59
+ }
60
+
61
+ #[test]
62
+ fn rate_now_ms_is_stable_across_calls() {
63
+ // Two calls back-to-back must agree within a wide window (both derive
64
+ // from the same monotonic clock + wall offset).
65
+ let a = rate_now_ms();
66
+ let b = rate_now_ms();
67
+ assert!(b >= a);
68
+ assert!(b - a < 5_000);
69
+ }
70
+ }