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,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
|
+
}
|