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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,538 @@
1
+ // rust/ingress/rate_limit.rs — sharded per-IP rate limiter + shared registry.
2
+
3
+ use crate::crypto::hashing::fast_hash_bytes;
4
+ use lru::LruCache;
5
+ use napi_derive::napi;
6
+ use parking_lot::Mutex;
7
+ use std::num::NonZeroUsize;
8
+ use std::sync::atomic::{AtomicU64, Ordering};
9
+ use std::sync::{Arc, OnceLock};
10
+
11
+ static LIMITER_ID: AtomicU64 = AtomicU64::new(0);
12
+
13
+ struct Counter {
14
+ window_start: u64,
15
+ prev: u32,
16
+ curr: u32,
17
+ }
18
+
19
+ #[repr(align(64))]
20
+ struct Shard {
21
+ map: Mutex<LruCache<u64, Counter>>,
22
+ }
23
+
24
+ pub struct RateOutcome {
25
+ pub allowed: bool,
26
+ pub remaining: u32,
27
+ pub reset_ms: u64,
28
+ }
29
+
30
+ /// Whether an ingress instance has rate limiting configured.
31
+ ///
32
+ /// This enum replaces the old `bool` + `Option` pair so the code cannot
33
+ /// accidentally have `rate_enabled = true` with no limiter (or vice versa).
34
+ #[derive(Clone)]
35
+ pub(crate) enum RateLimiterState {
36
+ /// Rate limiting is not configured.
37
+ Disabled,
38
+ /// Rate limiting is configured and ready to use.
39
+ Enabled(Arc<KeyedRateLimiter>),
40
+ }
41
+
42
+ impl RateLimiterState {
43
+ /// Returns the underlying limiter, or `None` when disabled.
44
+ #[inline(always)]
45
+ pub fn as_limiter(&self) -> Option<&KeyedRateLimiter> {
46
+ match self {
47
+ RateLimiterState::Disabled => None,
48
+ RateLimiterState::Enabled(limiter) => Some(limiter.as_ref()),
49
+ }
50
+ }
51
+ }
52
+
53
+ pub struct KeyedRateLimiter {
54
+ shards: Box<[Shard]>,
55
+ shard_mask: usize,
56
+ limit: u32,
57
+ window_ms: u64,
58
+ seed: u64,
59
+ }
60
+
61
+ impl KeyedRateLimiter {
62
+ pub fn new(limit: u32, window_ms: u32, max_entries: Option<usize>) -> Self {
63
+ const SHARD_COUNT: usize = 256;
64
+
65
+ let id = LIMITER_ID.fetch_add(1, Ordering::Relaxed);
66
+ let seed = fast_hash_bytes(&id.to_le_bytes());
67
+
68
+ let max_entries = max_entries
69
+ .unwrap_or(DEFAULT_MAX_ENTRIES)
70
+ .min(MAX_ENTRIES_CAP);
71
+ let max_per_shard = (max_entries / SHARD_COUNT).max(64);
72
+
73
+ // `max_per_shard` is clamped to >= 64 above, so this cannot fail.
74
+ let cap = NonZeroUsize::new(max_per_shard).expect("max_per_shard is clamped to >= 64");
75
+
76
+ let shards = (0..SHARD_COUNT)
77
+ .map(|_| Shard {
78
+ map: Mutex::new(LruCache::new(cap)),
79
+ })
80
+ .collect();
81
+
82
+ Self {
83
+ shards,
84
+ shard_mask: SHARD_COUNT - 1,
85
+ limit,
86
+ window_ms: window_ms.max(1) as u64,
87
+ seed,
88
+ }
89
+ }
90
+
91
+ #[inline(always)]
92
+ pub fn seed(&self) -> u64 {
93
+ self.seed
94
+ }
95
+
96
+ #[inline(always)]
97
+ pub fn limit(&self) -> u32 {
98
+ self.limit
99
+ }
100
+
101
+ pub fn check_key(&self, key: u64, now_ms: u64) -> RateOutcome {
102
+ if self.limit == 0 {
103
+ return RateOutcome {
104
+ allowed: false,
105
+ remaining: 0,
106
+ reset_ms: now_ms.saturating_add(self.window_ms),
107
+ };
108
+ }
109
+
110
+ if self.limit == u32::MAX {
111
+ return RateOutcome {
112
+ allowed: true,
113
+ remaining: u32::MAX,
114
+ reset_ms: now_ms.saturating_add(self.window_ms),
115
+ };
116
+ }
117
+
118
+ let shard = &self.shards[(key as usize) & self.shard_mask];
119
+ let mut map = shard.map.lock();
120
+
121
+ let window = self.window_ms.max(1);
122
+
123
+ let c = map.get_or_insert_mut(key, || Counter {
124
+ window_start: now_ms,
125
+ prev: 0,
126
+ curr: 0,
127
+ });
128
+
129
+ advance_window(c, now_ms, window);
130
+
131
+ let elapsed = now_ms.saturating_sub(c.window_start);
132
+ let overlap = window.saturating_sub(elapsed.min(window));
133
+
134
+ let weighted = ((c.prev as u128) * (overlap as u128) / (window as u128)) + (c.curr as u128);
135
+
136
+ let reset = c.window_start.saturating_add(window);
137
+
138
+ if weighted < self.limit as u128 {
139
+ c.curr = c.curr.saturating_add(1);
140
+
141
+ let remaining = (self.limit as u128).saturating_sub(weighted.saturating_add(1)) as u32;
142
+
143
+ RateOutcome {
144
+ allowed: true,
145
+ remaining,
146
+ reset_ms: reset,
147
+ }
148
+ } else {
149
+ RateOutcome {
150
+ allowed: false,
151
+ remaining: 0,
152
+ reset_ms: reset,
153
+ }
154
+ }
155
+ }
156
+ }
157
+
158
+ fn advance_window(c: &mut Counter, now_ms: u64, window: u64) {
159
+ let elapsed = now_ms.saturating_sub(c.window_start);
160
+
161
+ if elapsed < window {
162
+ return;
163
+ }
164
+
165
+ let double_window = window.saturating_mul(2);
166
+
167
+ if elapsed >= double_window {
168
+ c.window_start = now_ms;
169
+ c.prev = 0;
170
+ c.curr = 0;
171
+ } else {
172
+ c.window_start = c.window_start.saturating_add(window);
173
+ c.prev = c.curr;
174
+ c.curr = 0;
175
+ }
176
+ }
177
+
178
+ // ── Shared limiter registry ───────────────────────────────────────
179
+ // Rate limiting must be shared across all ingress instances/routes in the
180
+ // process, otherwise a client can bypass a per-IP limit by spreading requests
181
+ // across endpoints (each route would get its own independent bucket). All
182
+ // instances configured with the same (limit, window_ms, max_entries) share one
183
+ // process-wide limiter.
184
+ //
185
+ // The registry is BOUNDED: each distinct config materializes a full 256-shard
186
+ // limiter (~100k LRU nodes at the default max_entries, ~MBs), so an
187
+ // unbounded map would let many slightly-different configs balloon memory. We
188
+ // cap the number of retained configs (LRU eviction) and clamp max_entries.
189
+ #[derive(Clone, Copy, PartialEq, Eq, Hash)]
190
+ struct LimiterKey {
191
+ limit: u32,
192
+ window_ms: u32,
193
+ max_entries: usize,
194
+ }
195
+
196
+ /// Maximum number of distinct rate-limit configs retained process-wide.
197
+ const MAX_SHARED_LIMITERS: usize = 16;
198
+ /// Upper clamp for `max_entries` (prevents a ~4 GB allocation from a bad input).
199
+ const MAX_ENTRIES_CAP: usize = 4_000_000;
200
+ /// Default per-config entry budget (256 shards × ~390 nodes). Lowered from
201
+ /// ~1M so a default-configured limiter stays ~1 MB instead of tens of MB.
202
+ /// Rate limiting is opt-in; callers that need a larger budget can pass
203
+ /// `maxEntries` explicitly.
204
+ pub(crate) const DEFAULT_MAX_ENTRIES: usize = 100_000;
205
+
206
+ static SHARED_LIMITERS: OnceLock<Mutex<LruCache<LimiterKey, Arc<KeyedRateLimiter>>>> =
207
+ OnceLock::new();
208
+
209
+ /// Return the process-wide shared limiter for the given configuration.
210
+ /// Instances with identical configuration share the same underlying limiter.
211
+ pub fn shared_limiter(
212
+ limit: u32,
213
+ window_ms: u32,
214
+ max_entries: Option<usize>,
215
+ ) -> std::result::Result<Arc<KeyedRateLimiter>, String> {
216
+ let resolved = max_entries
217
+ .unwrap_or(DEFAULT_MAX_ENTRIES)
218
+ .min(MAX_ENTRIES_CAP);
219
+ let key = LimiterKey {
220
+ limit,
221
+ window_ms,
222
+ max_entries: resolved,
223
+ };
224
+
225
+ let map = SHARED_LIMITERS.get_or_init(|| {
226
+ Mutex::new(LruCache::new(
227
+ NonZeroUsize::new(MAX_SHARED_LIMITERS).expect("MAX_SHARED_LIMITERS is nonzero"),
228
+ ))
229
+ });
230
+ let mut guard = map.lock();
231
+
232
+ if let Some(existing) = guard.get(&key) {
233
+ return Ok(existing.clone());
234
+ }
235
+
236
+ // Bounded registry: instead of SILENTLY evicting a live limiter (which
237
+ // would split one logical rate budget across two limiters and reset
238
+ // per-IP counters — a rate-limit bypass vector), refuse the (cap+1)th
239
+ // distinct config. Callers should reuse an existing
240
+ // (limit, window_ms, max_entries) configuration.
241
+ if guard.len() >= MAX_SHARED_LIMITERS {
242
+ return Err(format!(
243
+ "castrum: too many distinct rate-limit configurations ({MAX_SHARED_LIMITERS}); \
244
+ a shared limiter would be evicted and its per-IP budget reset. \
245
+ Reuse an existing (limit, window_ms, max_entries) config."
246
+ ));
247
+ }
248
+
249
+ let limiter = Arc::new(KeyedRateLimiter::new(limit, window_ms, Some(resolved)));
250
+ guard.put(key, limiter.clone());
251
+ Ok(limiter)
252
+ }
253
+
254
+ // ── Standalone native rate limiter ────────────────────────────────
255
+ // A napi wrapper over the same sharded fixed-window limiter the ingress
256
+ // pipeline uses, exposed as a plain class so ANY app can get native per-key
257
+ // rate limiting without mounting the full ingress pipeline. Each `RateLimiter`
258
+ // instance owns an independent budget (unlike the ingress shared registry,
259
+ // which dedupes by config — standalone instances are intentionally isolated).
260
+
261
+ /// Result of a rate-limit check for one key at a point in time.
262
+ #[napi(object)]
263
+ pub struct RateCheck {
264
+ /// Whether the request is allowed.
265
+ pub allowed: bool,
266
+ /// Remaining requests in the current window (saturating).
267
+ pub remaining: u32,
268
+ /// Unix milliseconds when the window resets.
269
+ pub reset_ms: i64,
270
+ }
271
+
272
+ /// Sharded fixed-window per-key rate limiter (fixed-window, weighted overlap).
273
+ #[napi]
274
+ pub struct RateLimiter {
275
+ inner: Arc<KeyedRateLimiter>,
276
+ }
277
+
278
+ #[napi]
279
+ impl RateLimiter {
280
+ #[napi(constructor)]
281
+ pub fn new(limit: u32, window_ms: u32, max_entries: Option<u32>) -> Self {
282
+ Self {
283
+ inner: Arc::new(KeyedRateLimiter::new(
284
+ limit,
285
+ window_ms,
286
+ max_entries.map(|v| v as usize),
287
+ )),
288
+ }
289
+ }
290
+
291
+ /// Opaque handle to the limiter state, for the `bun:ffi` C-ABI fast path
292
+ /// (`castrum_rate_limiter_check*` in rust/ffi.rs). Only valid while THIS
293
+ /// instance is alive; the JS wrapper holds the instance.
294
+ #[napi]
295
+ pub fn inner_ptr(&self) -> u64 {
296
+ self as *const RateLimiter as u64
297
+ }
298
+
299
+ /// Check a rate limit for a string key (hashed internally) at `now_ms`.
300
+ #[napi]
301
+ pub fn check(&self, key: String, now_ms: f64) -> RateCheck {
302
+ let outcome = self
303
+ .inner
304
+ .check_key(fast_hash_bytes(key.as_bytes()), now_ms as u64);
305
+ RateCheck {
306
+ allowed: outcome.allowed,
307
+ remaining: outcome.remaining,
308
+ reset_ms: outcome.reset_ms as i64,
309
+ }
310
+ }
311
+
312
+ /// Check a rate limit for a pre-hashed i64 key at `now_ms`.
313
+ #[napi]
314
+ pub fn check_key(&self, key: i64, now_ms: f64) -> RateCheck {
315
+ let outcome = self.inner.check_key(key as u64, now_ms as u64);
316
+ RateCheck {
317
+ allowed: outcome.allowed,
318
+ remaining: outcome.remaining,
319
+ reset_ms: outcome.reset_ms as i64,
320
+ }
321
+ }
322
+ }
323
+
324
+ /// C-ABI support: check a rate limit for a (possibly pre-hashed) key.
325
+ /// Returns `(allowed, remaining, reset_ms)`.
326
+ ///
327
+ /// # Safety
328
+ /// `p` must be a valid `*const RateLimiter` from `inner_ptr`, alive for the
329
+ /// call (the JS wrapper holds the napi instance).
330
+ pub(crate) unsafe fn rate_limiter_check_core(
331
+ p: *const RateLimiter,
332
+ key: u64,
333
+ now_ms: u64,
334
+ ) -> (bool, u32, i64) {
335
+ let this = &*p;
336
+ let o = this.inner.check_key(key, now_ms);
337
+ (o.allowed, o.remaining, o.reset_ms as i64)
338
+ }
339
+
340
+ #[cfg(test)]
341
+ mod tests {
342
+ use super::*;
343
+
344
+ #[test]
345
+ fn standalone_limiter_blocks_after_limit() {
346
+ let limiter = KeyedRateLimiter::new(2, 60_000, Some(1024));
347
+ let now = 1_700_000_000_000u64;
348
+ assert!(limiter.check_key(1, now).allowed);
349
+ assert!(limiter.check_key(1, now).allowed);
350
+ let third = limiter.check_key(1, now);
351
+ assert!(!third.allowed);
352
+ assert_eq!(third.remaining, 0);
353
+ // A different key is unaffected.
354
+ assert!(limiter.check_key(2, now).allowed);
355
+ }
356
+
357
+ #[test]
358
+ fn standalone_limiter_resets_after_window() {
359
+ let limiter = KeyedRateLimiter::new(1, 60_000, Some(1024));
360
+ let now = 1_700_000_000_000u64;
361
+ assert!(limiter.check_key(7, now).allowed);
362
+ assert!(!limiter.check_key(7, now).allowed);
363
+ // After two full windows the budget resets.
364
+ let later = now + 2 * 60_000;
365
+ assert!(limiter.check_key(7, later).allowed);
366
+ }
367
+
368
+ #[test]
369
+ fn standalone_limiter_independent_buckets() {
370
+ let a = KeyedRateLimiter::new(1, 60_000, Some(1024));
371
+ let b = KeyedRateLimiter::new(1, 60_000, Some(1024));
372
+ assert!(a.check_key(9, 1_700_000_000_000).allowed);
373
+ // `b` is a fresh independent budget — not shared with `a`.
374
+ assert!(b.check_key(9, 1_700_000_000_000).allowed);
375
+ }
376
+
377
+ // ── Cross-module parity (moved from unit_tests.rs) ─────────────
378
+
379
+ #[test]
380
+ fn rate_limit_allows_up_to_limit_within_window() {
381
+ let rl = KeyedRateLimiter::new(5, 1000, None);
382
+ let key = 42;
383
+
384
+ for i in 0..5 {
385
+ let o = rl.check_key(key, 100 + i as u64);
386
+ assert!(o.allowed, "request {} should be allowed", i);
387
+ let expected = (5 - i - 1) as u32;
388
+ assert_eq!(o.remaining, expected, "remaining mismatch at request {}", i);
389
+ }
390
+
391
+ let denied = rl.check_key(key, 105);
392
+ assert!(
393
+ !denied.allowed,
394
+ "6th request within window should be denied"
395
+ );
396
+ assert_eq!(denied.remaining, 0);
397
+ }
398
+
399
+ #[test]
400
+ fn rate_limit_denied_after_limit() {
401
+ let rl = KeyedRateLimiter::new(2, 60_000, None);
402
+ assert!(rl.check_key(1, 0).allowed);
403
+ assert!(rl.check_key(1, 1).allowed);
404
+ assert!(!rl.check_key(1, 2).allowed);
405
+ }
406
+
407
+ #[test]
408
+ fn rate_limit_window_advances_and_recovers() {
409
+ let rl = KeyedRateLimiter::new(2, 1000, None);
410
+ let key = 7;
411
+
412
+ assert!(rl.check_key(key, 0).allowed);
413
+ assert!(rl.check_key(key, 1).allowed);
414
+ assert!(!rl.check_key(key, 2).allowed);
415
+
416
+ // New window: previous weight decays; at least one slot must open.
417
+ let o = rl.check_key(key, 1001);
418
+ assert!(o.allowed, "request after window rollover should be allowed");
419
+ assert!(o.reset_ms > 1001, "reset should be in the future");
420
+ }
421
+
422
+ #[test]
423
+ fn rate_limit_reset_ms_is_in_future() {
424
+ let rl = KeyedRateLimiter::new(3, 5000, None);
425
+ let o = rl.check_key(9, 1000);
426
+ assert!(o.allowed);
427
+ assert!(o.reset_ms > 1000);
428
+ }
429
+
430
+ #[test]
431
+ fn rate_limit_zero_limit_denies_everything() {
432
+ let rl = KeyedRateLimiter::new(0, 1000, None);
433
+ let o = rl.check_key(1, 0);
434
+ assert!(!o.allowed);
435
+ assert_eq!(o.remaining, 0);
436
+ }
437
+
438
+ #[test]
439
+ fn rate_limit_max_limit_allows_everything() {
440
+ let rl = KeyedRateLimiter::new(u32::MAX, 1000, None);
441
+ let o = rl.check_key(1, 0);
442
+ assert!(o.allowed);
443
+ assert_eq!(o.remaining, u32::MAX);
444
+ }
445
+
446
+ #[test]
447
+ fn rate_limit_keys_are_independent() {
448
+ let rl = KeyedRateLimiter::new(1, 1000, None);
449
+ assert!(rl.check_key(1, 0).allowed);
450
+ assert!(!rl.check_key(1, 1).allowed);
451
+ assert!(
452
+ rl.check_key(2, 1).allowed,
453
+ "different key should have its own bucket"
454
+ );
455
+ }
456
+
457
+ #[test]
458
+ fn rate_limit_seed_is_stable_per_instance() {
459
+ let a = KeyedRateLimiter::new(10, 1000, None);
460
+ let b = KeyedRateLimiter::new(10, 1000, None);
461
+ // Seeds differ per instance (unique per limiter id) but are stable within one.
462
+ assert_ne!(a.seed(), b.seed());
463
+ assert_eq!(a.seed(), a.seed());
464
+ }
465
+
466
+ #[test]
467
+ fn rate_limit_shared_limiter_is_shared_by_config() {
468
+ use std::sync::Arc;
469
+
470
+ let a = super::shared_limiter(100, 60_000, None).unwrap();
471
+ let b = super::shared_limiter(100, 60_000, None).unwrap();
472
+ assert!(
473
+ Arc::ptr_eq(&a, &b),
474
+ "identical config must share one process-wide limiter"
475
+ );
476
+
477
+ let c = super::shared_limiter(100, 60_000, Some(10_000)).unwrap();
478
+ assert!(
479
+ !Arc::ptr_eq(&a, &c),
480
+ "different max_entries must not share a limiter"
481
+ );
482
+
483
+ let d = super::shared_limiter(200, 60_000, None).unwrap();
484
+ assert!(
485
+ !Arc::ptr_eq(&a, &d),
486
+ "different limit must not share a limiter"
487
+ );
488
+ }
489
+
490
+ #[test]
491
+ fn rate_limit_shared_limiter_refuses_17th_distinct_config() {
492
+ // The registry is BOUNDED (MAX_SHARED_LIMITERS = 16) and must never
493
+ // SILENTLY evict a live limiter (eviction resets per-IP budgets — a
494
+ // rate-limit bypass vector). Fill it with 16 distinct configs (starting
495
+ // with the 4 the other shared_limiter tests already register, so this is
496
+ // deterministic regardless of test order), then assert a 17th throws.
497
+ let mut configs: Vec<(u32, u32, usize)> = vec![
498
+ (100, 60_000, crate::ingress::rate_limit::DEFAULT_MAX_ENTRIES), // matches shared_limiter(100, 60_000, None)
499
+ (100, 60_000, 10_000), // matches shared_limiter(100, 60_000, Some(10_000))
500
+ (200, 60_000, crate::ingress::rate_limit::DEFAULT_MAX_ENTRIES), // matches shared_limiter(200, 60_000, None)
501
+ (2, 60_000, crate::ingress::rate_limit::DEFAULT_MAX_ENTRIES), // matches shared_limiter(2, 60_000, None)
502
+ ];
503
+ for i in 0..12u32 {
504
+ configs.push((300 + i, 60_000, 1000 + i as usize));
505
+ }
506
+ for &(limit, window, max_entries) in &configs {
507
+ let _ = super::shared_limiter(limit, window, Some(max_entries))
508
+ .expect("distinct config registers");
509
+ }
510
+ // Registry at capacity: a genuinely new config must error, not evict.
511
+ let res = super::shared_limiter(500_000, 60_000, Some(123_456));
512
+ let err = match res {
513
+ Ok(_) => panic!("17th distinct config must be refused, not silently evicted"),
514
+ Err(e) => e,
515
+ };
516
+ assert!(
517
+ err.contains("too many distinct rate-limit configurations"),
518
+ "unexpected error: {err}"
519
+ );
520
+ }
521
+
522
+ #[test]
523
+ fn rate_limit_shared_limiter_shares_budget() {
524
+ // Two instances with the same config share one bucket — a request consumed
525
+ // via one instance must count against the other (prevents route-splitting
526
+ // bypass).
527
+ let a = super::shared_limiter(2, 60_000, None).unwrap();
528
+ let b = super::shared_limiter(2, 60_000, None).unwrap();
529
+ let key = 1234u64;
530
+
531
+ assert!(a.check_key(key, 0).allowed);
532
+ assert!(b.check_key(key, 1).allowed, "shared budget consumed by a");
533
+ assert!(
534
+ !a.check_key(key, 2).allowed,
535
+ "budget must be exhausted across both instances"
536
+ );
537
+ }
538
+ }
@@ -0,0 +1,114 @@
1
+ // rust/ingress/terminal.rs — Cold-path terminal response writers
2
+ // These functions are #[cold] so they don't pollute the hot-path
3
+ // instruction cache. They build responses via `HeaderFields` and never fail
4
+ // (writers are capacity-checked), so they return the written byte count
5
+ // directly instead of a Result.
6
+
7
+ use crate::ingress::output::{
8
+ HeaderFields, RateInfo, ERR_CODE_BODY_TOO_LARGE, ERR_CODE_CORS_PREFLIGHT,
9
+ ERR_CODE_INVALID_JSON, ERR_CODE_NONE, ERR_CODE_RATE_LIMITED, ERR_CODE_SCHEMA_VALIDATION,
10
+ HV_JSON,
11
+ };
12
+
13
+ /// Write a simple terminal response (no CORS, no rate limiting).
14
+ #[cold]
15
+ pub(crate) fn terminal_simple(out: &mut [u8], error_code: u8, status: u16) -> usize {
16
+ HeaderFields::terminal(1, error_code, status, 0, RateInfo::default(), HV_JSON).write(out)
17
+ }
18
+
19
+ /// Write a preflight OK response (204).
20
+ #[cold]
21
+ pub(crate) fn terminal_preflight_ok(flags: u32, hv: u8, out: &mut [u8]) -> usize {
22
+ HeaderFields::terminal(2, ERR_CODE_NONE, 204, flags, RateInfo::default(), hv).write(out)
23
+ }
24
+
25
+ /// Write a preflight forbidden response (403).
26
+ #[cold]
27
+ pub(crate) fn terminal_preflight_forbidden(flags: u32, hv: u8, out: &mut [u8]) -> usize {
28
+ HeaderFields::terminal(
29
+ 2,
30
+ ERR_CODE_CORS_PREFLIGHT,
31
+ 403,
32
+ flags,
33
+ RateInfo::default(),
34
+ hv,
35
+ )
36
+ .write(out)
37
+ }
38
+
39
+ /// Write a rate-limited response (429).
40
+ #[cold]
41
+ pub(crate) fn terminal_rate_limited(flags: u32, hv: u8, rate: RateInfo, out: &mut [u8]) -> usize {
42
+ HeaderFields::terminal(1, ERR_CODE_RATE_LIMITED, 429, flags, rate, hv).write(out)
43
+ }
44
+
45
+ /// Write a body-too-large response (413).
46
+ #[cold]
47
+ pub(crate) fn terminal_body_too_large(flags: u32, hv: u8, rate: RateInfo, out: &mut [u8]) -> usize {
48
+ HeaderFields::terminal(1, ERR_CODE_BODY_TOO_LARGE, 413, flags, rate, hv).write(out)
49
+ }
50
+
51
+ /// Write an invalid JSON response (400).
52
+ #[cold]
53
+ pub(crate) fn terminal_invalid_json(flags: u32, hv: u8, rate: RateInfo, out: &mut [u8]) -> usize {
54
+ HeaderFields::terminal(1, ERR_CODE_INVALID_JSON, 400, flags, rate, hv).write(out)
55
+ }
56
+
57
+ /// Write a schema validation failure response (422).
58
+ #[cold]
59
+ pub(crate) fn terminal_schema_validation(
60
+ flags: u32,
61
+ hv: u8,
62
+ rate: RateInfo,
63
+ out: &mut [u8],
64
+ ) -> usize {
65
+ HeaderFields::terminal(1, ERR_CODE_SCHEMA_VALIDATION, 422, flags, rate, hv).write(out)
66
+ }
67
+
68
+ #[cfg(test)]
69
+ mod tests {
70
+ use super::*;
71
+ use crate::ingress::output::{
72
+ HV_CORS_PREFLIGHT, OUT_DATA_START, OUT_ERROR_CODE, OUT_STATUS, OUT_VERDICT,
73
+ };
74
+
75
+ fn u16_at(out: &[u8], off: usize) -> u16 {
76
+ u16::from_le_bytes([out[off], out[off + 1]])
77
+ }
78
+
79
+ #[test]
80
+ fn terminal_simple_writes_error_layout() {
81
+ let mut out = vec![0u8; 128];
82
+ let written = terminal_simple(&mut out, ERR_CODE_INVALID_JSON, 400);
83
+ // Terminal responses write the fixed 48-byte header with no data payload.
84
+ assert_eq!(written, OUT_DATA_START);
85
+ assert_eq!(out[OUT_VERDICT], 1);
86
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_INVALID_JSON);
87
+ assert_eq!(u16_at(&out, OUT_STATUS), 400);
88
+ }
89
+
90
+ #[test]
91
+ fn terminal_rate_limited_writes_429_and_rate_info() {
92
+ let mut out = vec![0u8; 128];
93
+ let rate = RateInfo {
94
+ limit: 10,
95
+ remaining: 0,
96
+ reset_ms: 5,
97
+ retry_after_ms: 7,
98
+ };
99
+ let written = terminal_rate_limited(0, HV_JSON, rate, &mut out);
100
+ assert_eq!(written, OUT_DATA_START);
101
+ assert_eq!(out[OUT_VERDICT], 1);
102
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_RATE_LIMITED);
103
+ assert_eq!(u16_at(&out, OUT_STATUS), 429);
104
+ }
105
+
106
+ #[test]
107
+ fn terminal_preflight_ok_is_204() {
108
+ let mut out = vec![0u8; 128];
109
+ let written = terminal_preflight_ok(0, HV_CORS_PREFLIGHT, &mut out);
110
+ assert_eq!(written, OUT_DATA_START);
111
+ assert_eq!(u16_at(&out, OUT_STATUS), 204);
112
+ assert_eq!(out[OUT_ERROR_CODE], ERR_CODE_NONE);
113
+ }
114
+ }