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,324 @@
|
|
|
1
|
+
// rust/http/url_join.rs — URL building: RFC 3986 reference resolution + query builder.
|
|
2
|
+
//
|
|
3
|
+
// `url_resolve(base, reference)` resolves a relative/absolute reference against
|
|
4
|
+
// a base URI (merge paths + remove dot segments). `url_encode_query` builds a
|
|
5
|
+
// percent-encoded query string (deterministic sorted-key order). The
|
|
6
|
+
// `UrlBuilder` higher-order instance parses the base once and reuses it.
|
|
7
|
+
|
|
8
|
+
use std::collections::BTreeMap;
|
|
9
|
+
|
|
10
|
+
use napi::bindgen_prelude::*;
|
|
11
|
+
use napi_derive::napi;
|
|
12
|
+
|
|
13
|
+
/// A parsed URI reference (RFC 3986 §4.2 components).
|
|
14
|
+
#[derive(Debug, Clone)]
|
|
15
|
+
pub(crate) struct Ref {
|
|
16
|
+
scheme: Option<String>,
|
|
17
|
+
authority: Option<String>,
|
|
18
|
+
path: String,
|
|
19
|
+
query: Option<String>,
|
|
20
|
+
fragment: Option<String>,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
pub(crate) fn parse_ref(input: &str) -> Ref {
|
|
24
|
+
let (rest, fragment) = match input.find('#') {
|
|
25
|
+
Some(i) => (&input[..i], Some(input[i + 1..].to_string())),
|
|
26
|
+
None => (input, None),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let (rest, scheme) = if let Some(i) = rest.find(':') {
|
|
30
|
+
let candidate = &rest[..i];
|
|
31
|
+
if !candidate.is_empty()
|
|
32
|
+
&& candidate
|
|
33
|
+
.chars()
|
|
34
|
+
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '-' || c == '.')
|
|
35
|
+
{
|
|
36
|
+
(&rest[i + 1..], Some(candidate.to_string()))
|
|
37
|
+
} else {
|
|
38
|
+
(rest, None)
|
|
39
|
+
}
|
|
40
|
+
} else {
|
|
41
|
+
(rest, None)
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
let (rest, authority) = if let Some(rest2) = rest.strip_prefix("//") {
|
|
45
|
+
let end = rest2.find('/').unwrap_or(rest2.len());
|
|
46
|
+
(&rest2[end..], Some(rest2[..end].to_string()))
|
|
47
|
+
} else {
|
|
48
|
+
(rest, None)
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
let (path, query) = match rest.find('?') {
|
|
52
|
+
Some(i) => (rest[..i].to_string(), Some(rest[i + 1..].to_string())),
|
|
53
|
+
None => (rest.to_string(), None),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
Ref {
|
|
57
|
+
scheme,
|
|
58
|
+
authority,
|
|
59
|
+
path,
|
|
60
|
+
query,
|
|
61
|
+
fragment,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// RFC 3986 §5.2.4 remove_dot_segments (normalize `.` / `..`).
|
|
66
|
+
fn remove_dot_segments(path: &str) -> String {
|
|
67
|
+
let leading = path.starts_with('/');
|
|
68
|
+
let parts: Vec<&str> = path.split('/').collect();
|
|
69
|
+
let mut segments: Vec<&str> = Vec::new();
|
|
70
|
+
let mut i = 0usize;
|
|
71
|
+
if parts.first() == Some(&"") {
|
|
72
|
+
i = 1;
|
|
73
|
+
}
|
|
74
|
+
while i < parts.len() {
|
|
75
|
+
match parts[i] {
|
|
76
|
+
"." => {}
|
|
77
|
+
".." => {
|
|
78
|
+
segments.pop();
|
|
79
|
+
}
|
|
80
|
+
seg => segments.push(seg),
|
|
81
|
+
}
|
|
82
|
+
i += 1;
|
|
83
|
+
}
|
|
84
|
+
let body = segments.join("/");
|
|
85
|
+
if leading {
|
|
86
|
+
format!("/{body}")
|
|
87
|
+
} else {
|
|
88
|
+
body
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// RFC 3986 §5.3.3 merge paths.
|
|
93
|
+
fn merge_paths(base_path: &str, ref_path: &str) -> String {
|
|
94
|
+
match base_path.rfind('/') {
|
|
95
|
+
Some(i) => format!("{}{}", &base_path[..=i], ref_path),
|
|
96
|
+
None => ref_path.to_string(),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
pub(crate) fn resolve_target(base: &Ref, r: &Ref) -> Ref {
|
|
101
|
+
if r.scheme.is_some() {
|
|
102
|
+
let mut t = r.clone();
|
|
103
|
+
t.path = remove_dot_segments(&r.path);
|
|
104
|
+
return t;
|
|
105
|
+
}
|
|
106
|
+
let mut t = Ref {
|
|
107
|
+
scheme: base.scheme.clone(),
|
|
108
|
+
authority: base.authority.clone(),
|
|
109
|
+
path: String::new(),
|
|
110
|
+
query: None,
|
|
111
|
+
fragment: r.fragment.clone(),
|
|
112
|
+
};
|
|
113
|
+
if r.authority.is_some() {
|
|
114
|
+
t.authority = r.authority.clone();
|
|
115
|
+
t.path = remove_dot_segments(&r.path);
|
|
116
|
+
t.query = r.query.clone();
|
|
117
|
+
return t;
|
|
118
|
+
}
|
|
119
|
+
if r.path.is_empty() {
|
|
120
|
+
t.path = base.path.clone();
|
|
121
|
+
t.query = r.query.clone().or_else(|| base.query.clone());
|
|
122
|
+
return t;
|
|
123
|
+
}
|
|
124
|
+
if r.path.starts_with('/') {
|
|
125
|
+
t.path = remove_dot_segments(&r.path);
|
|
126
|
+
} else {
|
|
127
|
+
t.path = remove_dot_segments(&merge_paths(&base.path, &r.path));
|
|
128
|
+
}
|
|
129
|
+
t.query = r.query.clone();
|
|
130
|
+
t
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
pub(crate) fn recompose(r: &Ref) -> String {
|
|
134
|
+
let mut s = String::new();
|
|
135
|
+
if let Some(sch) = &r.scheme {
|
|
136
|
+
s.push_str(sch);
|
|
137
|
+
s.push(':');
|
|
138
|
+
}
|
|
139
|
+
if let Some(auth) = &r.authority {
|
|
140
|
+
s.push_str("//");
|
|
141
|
+
s.push_str(auth);
|
|
142
|
+
}
|
|
143
|
+
s.push_str(&r.path);
|
|
144
|
+
if let Some(q) = &r.query {
|
|
145
|
+
s.push('?');
|
|
146
|
+
s.push_str(q);
|
|
147
|
+
}
|
|
148
|
+
if let Some(f) = &r.fragment {
|
|
149
|
+
s.push('#');
|
|
150
|
+
s.push_str(f);
|
|
151
|
+
}
|
|
152
|
+
s
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
#[napi]
|
|
156
|
+
pub fn url_resolve(base: Uint8Array, reference: Uint8Array) -> Result<Buffer> {
|
|
157
|
+
let b =
|
|
158
|
+
std::str::from_utf8(base.as_ref()).map_err(|_| Error::from_reason("base must be UTF-8"))?;
|
|
159
|
+
let r = std::str::from_utf8(reference.as_ref())
|
|
160
|
+
.map_err(|_| Error::from_reason("reference must be UTF-8"))?;
|
|
161
|
+
let target = resolve_target(&parse_ref(b), &parse_ref(r));
|
|
162
|
+
Ok(Buffer::from(recompose(&target).into_bytes()))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Packed URL-resolve batch (two packed lists: bases + references, zipped) →
|
|
166
|
+
/// `[u32 count]{[u32 len][resolved]}` out. Items that fail UTF-8 parsing yield
|
|
167
|
+
/// an empty result — skip-on-error.
|
|
168
|
+
#[napi]
|
|
169
|
+
pub fn url_resolve_batch_packed(bases: Uint8Array, references: Uint8Array) -> Result<Buffer> {
|
|
170
|
+
use crate::util::packed::PackedIter;
|
|
171
|
+
let b = bases.as_ref();
|
|
172
|
+
let r = references.as_ref();
|
|
173
|
+
let count = PackedIter::new(b)?.len().min(PackedIter::new(r)?.len());
|
|
174
|
+
let mut out = Vec::with_capacity(4 + count * 64);
|
|
175
|
+
out.extend_from_slice(&(count as u32).to_le_bytes());
|
|
176
|
+
for (base, reference) in PackedIter::new(b)?.zip(PackedIter::new(r)?) {
|
|
177
|
+
let resolved = match (std::str::from_utf8(base), std::str::from_utf8(reference)) {
|
|
178
|
+
(Ok(bs), Ok(rs)) => {
|
|
179
|
+
recompose(&resolve_target(&parse_ref(bs), &parse_ref(rs))).into_bytes()
|
|
180
|
+
}
|
|
181
|
+
_ => Vec::new(),
|
|
182
|
+
};
|
|
183
|
+
out.extend_from_slice(&(resolved.len() as u32).to_le_bytes());
|
|
184
|
+
out.extend_from_slice(&resolved);
|
|
185
|
+
}
|
|
186
|
+
Ok(Buffer::from(out))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
pub(crate) fn encode_query_component(
|
|
190
|
+
input: &[u8],
|
|
191
|
+
scratch: &mut Vec<u8>,
|
|
192
|
+
out: &mut Vec<u8>,
|
|
193
|
+
) -> Result<()> {
|
|
194
|
+
// Reuse one caller-provided scratch buffer across all keys/values instead of
|
|
195
|
+
// allocating a fresh `vec![0u8; len*3]` per component. Grows only when the
|
|
196
|
+
// largest component needs more room.
|
|
197
|
+
if scratch.len() < input.len().saturating_mul(3) {
|
|
198
|
+
scratch.resize(input.len().saturating_mul(3), 0);
|
|
199
|
+
}
|
|
200
|
+
let written = crate::http::url_codec::url_encode_into_slice(input, scratch)?;
|
|
201
|
+
out.extend_from_slice(&scratch[..written]);
|
|
202
|
+
Ok(())
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[napi]
|
|
206
|
+
pub fn url_encode_query(params: BTreeMap<String, String>) -> Result<Buffer> {
|
|
207
|
+
let mut out = Vec::new();
|
|
208
|
+
let mut scratch = Vec::new();
|
|
209
|
+
for (i, (k, v)) in params.iter().enumerate() {
|
|
210
|
+
if i > 0 {
|
|
211
|
+
out.push(b'&');
|
|
212
|
+
}
|
|
213
|
+
encode_query_component(k.as_bytes(), &mut scratch, &mut out)?;
|
|
214
|
+
out.push(b'=');
|
|
215
|
+
encode_query_component(v.as_bytes(), &mut scratch, &mut out)?;
|
|
216
|
+
}
|
|
217
|
+
Ok(Buffer::from(out))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// Higher-order instance: base parsed once, reused across resolves.
|
|
221
|
+
#[napi]
|
|
222
|
+
pub struct UrlBuilder {
|
|
223
|
+
base: Ref,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// C-ABI support: resolve `reference` against the precompiled base, returning
|
|
227
|
+
/// the UTF-8 bytes. `Err(())` on non-UTF-8 reference (napi parity: throws).
|
|
228
|
+
///
|
|
229
|
+
/// # Safety
|
|
230
|
+
/// `p` must be a valid `*const UrlBuilder` obtained from `inner_ptr` and must
|
|
231
|
+
/// stay alive for the call (the JS wrapper holds the napi instance).
|
|
232
|
+
pub(crate) unsafe fn url_builder_resolve_core(
|
|
233
|
+
p: *const UrlBuilder,
|
|
234
|
+
reference: &[u8],
|
|
235
|
+
) -> std::result::Result<Vec<u8>, ()> {
|
|
236
|
+
let r = std::str::from_utf8(reference).map_err(|_| ())?;
|
|
237
|
+
let this = &*p;
|
|
238
|
+
Ok(recompose(&resolve_target(&this.base, &parse_ref(r))).into_bytes())
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[napi]
|
|
242
|
+
impl UrlBuilder {
|
|
243
|
+
#[napi(constructor)]
|
|
244
|
+
pub fn new(base: Uint8Array) -> Result<Self> {
|
|
245
|
+
let b = std::str::from_utf8(base.as_ref())
|
|
246
|
+
.map_err(|_| Error::from_reason("base must be UTF-8"))?;
|
|
247
|
+
Ok(Self { base: parse_ref(b) })
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/// Opaque handle to the precompiled base, for the `bun:ffi` C-ABI fast path
|
|
251
|
+
/// (`castrum_url_builder_resolve` in rust/ffi.rs). Only valid while THIS
|
|
252
|
+
/// instance is alive; the JS wrapper holds the instance for the handle
|
|
253
|
+
/// lifetime (same contract as `Ingress::ingress_inner_ptr`).
|
|
254
|
+
#[napi]
|
|
255
|
+
pub fn inner_ptr(&self) -> u64 {
|
|
256
|
+
self as *const UrlBuilder as u64
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[napi]
|
|
260
|
+
pub fn resolve(&self, reference: Uint8Array) -> Result<Buffer> {
|
|
261
|
+
let r = std::str::from_utf8(reference.as_ref())
|
|
262
|
+
.map_err(|_| Error::from_reason("reference must be UTF-8"))?;
|
|
263
|
+
let target = resolve_target(&self.base, &parse_ref(r));
|
|
264
|
+
Ok(Buffer::from(recompose(&target).into_bytes()))
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#[cfg(test)]
|
|
269
|
+
mod tests {
|
|
270
|
+
use super::*;
|
|
271
|
+
|
|
272
|
+
fn resolve(base: &str, reference: &str) -> String {
|
|
273
|
+
recompose(&resolve_target(&parse_ref(base), &parse_ref(reference)))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#[test]
|
|
277
|
+
fn absolute_reference() {
|
|
278
|
+
assert_eq!(
|
|
279
|
+
resolve("http://a/b/c/d;p?q", "http://x/y/z"),
|
|
280
|
+
"http://x/y/z"
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
#[test]
|
|
285
|
+
fn authority_reference() {
|
|
286
|
+
assert_eq!(
|
|
287
|
+
resolve("http://a/b/c/d;p?q", "//other/path"),
|
|
288
|
+
"http://other/path"
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#[test]
|
|
293
|
+
fn dot_segment_removal() {
|
|
294
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "../../../g"), "http://a/g");
|
|
295
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "../../../../g"), "http://a/g");
|
|
296
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "./g"), "http://a/b/c/g");
|
|
297
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "g/./h"), "http://a/b/c/g/h");
|
|
298
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "g/../h"), "http://a/b/c/h");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn query_and_fragment() {
|
|
303
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "g?y#f"), "http://a/b/c/g?y#f");
|
|
304
|
+
assert_eq!(
|
|
305
|
+
resolve("http://a/b/c/d;p?q", "#frag"),
|
|
306
|
+
"http://a/b/c/d;p?q#frag"
|
|
307
|
+
);
|
|
308
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", "?y"), "http://a/b/c/d;p?y");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
#[test]
|
|
312
|
+
fn empty_path_reference_keeps_base_query() {
|
|
313
|
+
assert_eq!(resolve("http://a/b/c/d;p?q", ""), "http://a/b/c/d;p?q");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#[test]
|
|
317
|
+
fn query_builder_sorted_and_encoded() {
|
|
318
|
+
let mut params = BTreeMap::new();
|
|
319
|
+
params.insert("b".to_string(), "x y".to_string());
|
|
320
|
+
params.insert("a".to_string(), "1".to_string());
|
|
321
|
+
let out = url_encode_query(params).unwrap();
|
|
322
|
+
assert_eq!(out.as_ref(), b"a=1&b=x%20y");
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// rust/ingress/api.rs — the `Ingress` napi class + `IngressInner` state.
|
|
2
|
+
//
|
|
3
|
+
// The napi boundary of the ingress pipeline. `IngressInner` holds the compiled
|
|
4
|
+
// per-instance configuration (options, CORS, rate limiter, schema, limits); the
|
|
5
|
+
// `Ingress` class exposes the packed and full-sync request entry points. The
|
|
6
|
+
// core 8-stage pipeline logic lives in `pipeline.rs` (`IngressInner`'s
|
|
7
|
+
// `handle_packed`); this module only marshals options into state and routes
|
|
8
|
+
// requests into it. Kept as a separate file so `ingress/mod.rs` stays a pure
|
|
9
|
+
// declaration hub.
|
|
10
|
+
|
|
11
|
+
use napi::bindgen_prelude::*;
|
|
12
|
+
use napi::Status;
|
|
13
|
+
use napi_derive::napi;
|
|
14
|
+
|
|
15
|
+
use std::sync::Arc;
|
|
16
|
+
|
|
17
|
+
use super::{IngressOptions, IngressSchema, Limits};
|
|
18
|
+
use crate::ingress::output::{MAX_OUTPUT_BUFFER_SIZE, OUT_DATA_START};
|
|
19
|
+
use crate::ingress::rate_limit::RateLimiterState;
|
|
20
|
+
|
|
21
|
+
/// Clamp an ingress output-buffer size into `[OUT_DATA_START, MAX_OUTPUT_BUFFER_SIZE]`
|
|
22
|
+
/// so a misconfigured (huge/negative) `outputBufferSize` can never trigger a
|
|
23
|
+
/// multi-GB native allocation (OOM) in the allocating full_sync entry.
|
|
24
|
+
pub(crate) fn clamp_output_size(output_buffer_size: Option<u32>) -> usize {
|
|
25
|
+
output_buffer_size
|
|
26
|
+
.unwrap_or(262_144)
|
|
27
|
+
.clamp(OUT_DATA_START as u32, MAX_OUTPUT_BUFFER_SIZE) as usize
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Compiled per-instance ingress state. Constructed once by [`Ingress::new`]
|
|
31
|
+
/// from the JS-facing options; `handle_packed` (in `pipeline.rs`) runs the
|
|
32
|
+
/// 8-stage pipeline over this immutable configuration.
|
|
33
|
+
#[derive(Clone)]
|
|
34
|
+
pub(crate) struct IngressInner {
|
|
35
|
+
pub https_fixed: Option<bool>,
|
|
36
|
+
pub max_body_bytes: usize,
|
|
37
|
+
pub proxy_trust: crate::ingress::ip_trust::ProxyTrustMode,
|
|
38
|
+
pub parse_cookies: bool,
|
|
39
|
+
pub parse_query: bool,
|
|
40
|
+
pub require_json_body: bool,
|
|
41
|
+
pub guard_enabled: bool,
|
|
42
|
+
pub emit_metadata_json: bool,
|
|
43
|
+
pub cors_enabled: bool,
|
|
44
|
+
pub cors: crate::ingress::cors::CorsEngine,
|
|
45
|
+
pub rate: RateLimiterState,
|
|
46
|
+
pub schema: Option<Arc<IngressSchema>>,
|
|
47
|
+
pub limits: Limits,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
#[napi]
|
|
51
|
+
pub struct Ingress {
|
|
52
|
+
inner: Arc<IngressInner>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
#[napi]
|
|
56
|
+
impl Ingress {
|
|
57
|
+
#[napi(constructor)]
|
|
58
|
+
pub fn new(options: IngressOptions) -> Result<Self> {
|
|
59
|
+
let parse_cookies = options.parse_cookies.unwrap_or(false);
|
|
60
|
+
let parse_query = options.parse_query.unwrap_or(false);
|
|
61
|
+
let require_json_body = options.require_json_body.unwrap_or(false);
|
|
62
|
+
let max_body_bytes = options
|
|
63
|
+
.max_body_bytes
|
|
64
|
+
.unwrap_or(crate::ingress::options::DEFAULT_MAX_BODY_BYTES as u32)
|
|
65
|
+
as usize;
|
|
66
|
+
let guard_enabled = options.enable_body_size_guard.unwrap_or(true);
|
|
67
|
+
let emit_metadata_json = options.emit_metadata_json.unwrap_or(false);
|
|
68
|
+
|
|
69
|
+
let proxy_trust = if let Some(tp) = options.trusted_proxies {
|
|
70
|
+
crate::ingress::ip_trust::ProxyTrustMode::from_config(
|
|
71
|
+
tp.enabled.unwrap_or(false),
|
|
72
|
+
tp.networks,
|
|
73
|
+
)
|
|
74
|
+
.map_err(|e| Error::new(Status::InvalidArg, e))?
|
|
75
|
+
} else if options.trust_proxy.unwrap_or(false) {
|
|
76
|
+
crate::ingress::ip_trust::ProxyTrustMode::All
|
|
77
|
+
} else {
|
|
78
|
+
crate::ingress::ip_trust::ProxyTrustMode::None
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let schema = if let Some(schema_bytes) = options.schema {
|
|
82
|
+
let schema_str = std::str::from_utf8(&schema_bytes)
|
|
83
|
+
.map_err(|_| Error::new(Status::InvalidArg, "Invalid UTF-8 in schema"))?;
|
|
84
|
+
let schema_value: serde_json::Value = sonic_rs::from_str(schema_str)
|
|
85
|
+
.map_err(|e| Error::new(Status::InvalidArg, format!("Schema JSON error: {}", e)))?;
|
|
86
|
+
// Compile BOTH the authoritative jsonschema validator and the
|
|
87
|
+
// zero-DOM fast path once at construction — no per-request schema
|
|
88
|
+
// work (see IngressSchema in pipeline.rs).
|
|
89
|
+
let compiled = IngressSchema::compile(&schema_value).map_err(|e| {
|
|
90
|
+
Error::new(Status::InvalidArg, format!("Schema compile error: {}", e))
|
|
91
|
+
})?;
|
|
92
|
+
Some(Arc::new(compiled))
|
|
93
|
+
} else {
|
|
94
|
+
None
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
let cors_enabled = options.cors.is_some();
|
|
98
|
+
let cors = crate::ingress::cors::CorsEngine::from_options(options.cors)?;
|
|
99
|
+
|
|
100
|
+
let rate = if let Some(rl_opts) = options.rate_limit {
|
|
101
|
+
let limit = rl_opts.limit.unwrap_or(0);
|
|
102
|
+
let window_ms = rl_opts.window_ms.unwrap_or(1000);
|
|
103
|
+
if limit == 0 || limit == u32::MAX {
|
|
104
|
+
RateLimiterState::Disabled
|
|
105
|
+
} else {
|
|
106
|
+
let max_entries = rl_opts.max_entries.map(|v| v as usize);
|
|
107
|
+
// Shared per-configuration across the process: prevents a client
|
|
108
|
+
// from bypassing a per-IP limit by spreading requests across routes.
|
|
109
|
+
RateLimiterState::Enabled(
|
|
110
|
+
crate::ingress::rate_limit::shared_limiter(limit, window_ms, max_entries)
|
|
111
|
+
.map_err(|e| Error::new(Status::InvalidArg, e))?,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
RateLimiterState::Disabled
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
let limits = Limits::from_options(options.limits);
|
|
119
|
+
|
|
120
|
+
Ok(Self {
|
|
121
|
+
inner: Arc::new(IngressInner {
|
|
122
|
+
https_fixed: options.https,
|
|
123
|
+
max_body_bytes,
|
|
124
|
+
proxy_trust,
|
|
125
|
+
parse_cookies,
|
|
126
|
+
parse_query,
|
|
127
|
+
require_json_body,
|
|
128
|
+
guard_enabled,
|
|
129
|
+
emit_metadata_json,
|
|
130
|
+
cors_enabled,
|
|
131
|
+
cors,
|
|
132
|
+
rate,
|
|
133
|
+
schema,
|
|
134
|
+
limits,
|
|
135
|
+
}),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/// Opaque handle to the inner pipeline state, for the `bun:ffi` C-ABI fast
|
|
140
|
+
/// path (`castrum_ingress_handle_packed` in rust/ffi.rs).
|
|
141
|
+
///
|
|
142
|
+
/// # Safety / lifetime
|
|
143
|
+
/// The returned value is a raw pointer into the `Arc<IngressInner>` and is
|
|
144
|
+
/// ONLY valid while THIS `Ingress` instance is alive (napi keeps the native
|
|
145
|
+
/// state alive while the JS object is referenced; it is dropped on GC). It
|
|
146
|
+
/// must be treated as an opaque handle — JS must never dereference it, and
|
|
147
|
+
/// it must be discarded if the instance is dropped. The internal wrapper
|
|
148
|
+
/// (`src/native/ffi.ts`) holds the instance for the lifetime of the ingress
|
|
149
|
+
/// handler, so in practice the handle never outlives the state it points
|
|
150
|
+
/// to. Exposed for the internal fast path only; not part of the public API.
|
|
151
|
+
#[napi]
|
|
152
|
+
pub fn ingress_inner_ptr(&self) -> u64 {
|
|
153
|
+
self.inner.as_ref() as *const IngressInner as u64
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// Production API:
|
|
157
|
+
/// input = packed metadata frame
|
|
158
|
+
/// body = separate zero-copy body
|
|
159
|
+
/// output = response decision buffer
|
|
160
|
+
#[napi(ts_args_type = "input: Uint8Array, body: Uint8Array | null, output: Uint8Array")]
|
|
161
|
+
pub fn handle_request_packed(
|
|
162
|
+
&self,
|
|
163
|
+
input: Uint8Array,
|
|
164
|
+
body: Option<Uint8Array>,
|
|
165
|
+
mut output: Uint8Array,
|
|
166
|
+
) -> Result<u32> {
|
|
167
|
+
let inner: &IngressInner = self.inner.as_ref();
|
|
168
|
+
|
|
169
|
+
crate::util::run_packed_into(&input, &mut output, move |inp, out| match body {
|
|
170
|
+
Some(b) => {
|
|
171
|
+
let b_bytes = b.as_ref();
|
|
172
|
+
if crate::util::slices_overlap(b_bytes, out) {
|
|
173
|
+
let owned = b_bytes.to_vec();
|
|
174
|
+
inner.handle_packed(inp, &owned, out)
|
|
175
|
+
} else {
|
|
176
|
+
inner.handle_packed(inp, b_bytes, out)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
None => inner.handle_packed(inp, &[], out),
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// Optimized sync API:
|
|
184
|
+
/// Accepts raw JS values (no binary packing in JS), packs them in Rust,
|
|
185
|
+
/// and processes them synchronously.
|
|
186
|
+
/// This is the fastest path for simple requests.
|
|
187
|
+
///
|
|
188
|
+
/// Allocating variant: allocates a fresh output buffer per call. Hot paths
|
|
189
|
+
/// should prefer [`Self::handle_request_full_sync_into`], which reuses a
|
|
190
|
+
/// caller-provided (pooled) output buffer.
|
|
191
|
+
#[allow(clippy::too_many_arguments)]
|
|
192
|
+
#[napi(
|
|
193
|
+
ts_args_type = "methodKind: number, url: string, ip: string, requestId: string, headers: Array<[string, string]>, body: Uint8Array | null, outputBufferSize?: number"
|
|
194
|
+
)]
|
|
195
|
+
pub fn handle_request_full_sync(
|
|
196
|
+
&self,
|
|
197
|
+
method_kind: u32,
|
|
198
|
+
url: String,
|
|
199
|
+
ip: String,
|
|
200
|
+
request_id: String,
|
|
201
|
+
headers: Vec<Vec<String>>,
|
|
202
|
+
body: Option<Uint8Array>,
|
|
203
|
+
output_buffer_size: Option<u32>,
|
|
204
|
+
) -> Result<Uint8Array> {
|
|
205
|
+
// The fresh `out` vec cannot alias `body` (a distinct JS buffer), so it
|
|
206
|
+
// is safe to borrow the body without copying.
|
|
207
|
+
let body_bytes: &[u8] = match &body {
|
|
208
|
+
Some(b) => b.as_ref(),
|
|
209
|
+
None => &[],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// Default SMALL (the packed decision + metadata JSON is typically a few
|
|
213
|
+
// hundred bytes — a flat 256 KiB zero-fill per call is wasteful) and
|
|
214
|
+
// grow on the pipeline's "output buffer too small" signal, capped at
|
|
215
|
+
// `MAX_OUTPUT_BUFFER_SIZE`. Hot paths use the pooled `_into` variant.
|
|
216
|
+
let mut output_size = match output_buffer_size {
|
|
217
|
+
Some(s) => clamp_output_size(Some(s)),
|
|
218
|
+
// `OUT_DATA_START + 8192` is within `[OUT_DATA_START, MAX]` by
|
|
219
|
+
// construction, so no clamp is needed for the default.
|
|
220
|
+
None => OUT_DATA_START + 8192,
|
|
221
|
+
};
|
|
222
|
+
let mut out = vec![0u8; output_size];
|
|
223
|
+
let mut attempt_headers = headers.clone();
|
|
224
|
+
loop {
|
|
225
|
+
match self.full_sync_into(
|
|
226
|
+
method_kind,
|
|
227
|
+
&url,
|
|
228
|
+
&ip,
|
|
229
|
+
&request_id,
|
|
230
|
+
attempt_headers,
|
|
231
|
+
body_bytes,
|
|
232
|
+
&mut out,
|
|
233
|
+
) {
|
|
234
|
+
Ok(written) => {
|
|
235
|
+
out.truncate(written);
|
|
236
|
+
return Ok(Uint8Array::new(out));
|
|
237
|
+
}
|
|
238
|
+
Err(e)
|
|
239
|
+
if e.reason == "output buffer too small"
|
|
240
|
+
&& output_size < MAX_OUTPUT_BUFFER_SIZE as usize =>
|
|
241
|
+
{
|
|
242
|
+
// Grow (bounded) and retry with a fresh header marshal.
|
|
243
|
+
output_size = (output_size * 2).min(MAX_OUTPUT_BUFFER_SIZE as usize);
|
|
244
|
+
out.resize(output_size, 0);
|
|
245
|
+
attempt_headers = headers.clone();
|
|
246
|
+
}
|
|
247
|
+
Err(e) => return Err(e),
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Reusable-output variant of [`Self::handle_request_full_sync`]: packs raw
|
|
253
|
+
/// request components in Rust, runs the pipeline, and writes the packed
|
|
254
|
+
/// decision into the caller-provided `output` buffer, returning the number
|
|
255
|
+
/// of bytes written.
|
|
256
|
+
///
|
|
257
|
+
/// The caller is expected to pool the output buffer across requests (see
|
|
258
|
+
/// `src/shared/buffer-pool.ts`) to eliminate per-request allocation. The
|
|
259
|
+
/// body is zero-copied unless it aliases `output` (guarded by
|
|
260
|
+
/// `crate::util::slices_overlap`).
|
|
261
|
+
#[allow(clippy::too_many_arguments)]
|
|
262
|
+
#[napi(
|
|
263
|
+
ts_args_type = "methodKind: number, url: string, ip: string, requestId: string, headers: Array<[string, string]>, body: Uint8Array | null, output: Uint8Array"
|
|
264
|
+
)]
|
|
265
|
+
pub fn handle_request_full_sync_into(
|
|
266
|
+
&self,
|
|
267
|
+
method_kind: u32,
|
|
268
|
+
url: String,
|
|
269
|
+
ip: String,
|
|
270
|
+
request_id: String,
|
|
271
|
+
headers: Vec<Vec<String>>,
|
|
272
|
+
body: Option<Uint8Array>,
|
|
273
|
+
mut output: Uint8Array,
|
|
274
|
+
) -> Result<u32> {
|
|
275
|
+
let body_bytes: &[u8] = match &body {
|
|
276
|
+
Some(b) => b.as_ref(),
|
|
277
|
+
None => &[],
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// Check overlap before taking the mutable slice (can't alias output
|
|
281
|
+
// mutably and immutably at the same time).
|
|
282
|
+
let overlaps = crate::util::slices_overlap(body_bytes, output.as_ref());
|
|
283
|
+
|
|
284
|
+
if overlaps {
|
|
285
|
+
// The caller aliased body with output: fall back to an owned copy.
|
|
286
|
+
let owned = body_bytes.to_vec();
|
|
287
|
+
// SAFETY: `output` is a caller-provided buffer we write into and
|
|
288
|
+
// `full_sync_into` does not retain the slice beyond the call. This
|
|
289
|
+
// mirrors the existing `crate::util::run_packed_into` pattern.
|
|
290
|
+
let out: &mut [u8] = unsafe { output.as_mut() };
|
|
291
|
+
let written =
|
|
292
|
+
self.full_sync_into(method_kind, &url, &ip, &request_id, headers, &owned, out)?;
|
|
293
|
+
return Ok(written as u32);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// SAFETY: see above — the caller-provided buffer is only borrowed for
|
|
297
|
+
// the duration of the call.
|
|
298
|
+
let out: &mut [u8] = unsafe { output.as_mut() };
|
|
299
|
+
let written = self.full_sync_into(
|
|
300
|
+
method_kind,
|
|
301
|
+
&url,
|
|
302
|
+
&ip,
|
|
303
|
+
&request_id,
|
|
304
|
+
headers,
|
|
305
|
+
body_bytes,
|
|
306
|
+
out,
|
|
307
|
+
)?;
|
|
308
|
+
Ok(written as u32)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// Shared core for the full_sync family: packs raw request components in
|
|
312
|
+
/// Rust and runs the ingress pipeline, writing into `out` (which must be at
|
|
313
|
+
/// least `OUT_DATA_START` bytes). Returns the number of bytes written.
|
|
314
|
+
///
|
|
315
|
+
/// `body` is borrowed; callers must guarantee it does not alias `out` (the
|
|
316
|
+
/// into-variant checks overlap before calling).
|
|
317
|
+
#[allow(clippy::too_many_arguments)]
|
|
318
|
+
pub(crate) fn full_sync_into(
|
|
319
|
+
&self,
|
|
320
|
+
method_kind: u32,
|
|
321
|
+
url: &str,
|
|
322
|
+
ip: &str,
|
|
323
|
+
request_id: &str,
|
|
324
|
+
headers: Vec<Vec<String>>,
|
|
325
|
+
body: &[u8],
|
|
326
|
+
out: &mut [u8],
|
|
327
|
+
) -> Result<usize> {
|
|
328
|
+
let inner: &IngressInner = self.inner.as_ref();
|
|
329
|
+
|
|
330
|
+
// Consume the napi-marshaled header strings by moving the last two
|
|
331
|
+
// elements out of each Vec — avoids a second allocation per header
|
|
332
|
+
// name/value (napi already allocated the owned Strings).
|
|
333
|
+
let header_pairs: Vec<(String, String)> = headers
|
|
334
|
+
.into_iter()
|
|
335
|
+
.filter_map(|mut h| {
|
|
336
|
+
if h.len() < 2 {
|
|
337
|
+
return None;
|
|
338
|
+
}
|
|
339
|
+
let value = h.pop().unwrap_or_default();
|
|
340
|
+
let name = h.pop().unwrap_or_default();
|
|
341
|
+
Some((name, value))
|
|
342
|
+
})
|
|
343
|
+
.collect();
|
|
344
|
+
|
|
345
|
+
let packed = crate::ingress::packed::build_packed_input_sync(
|
|
346
|
+
method_kind as u8,
|
|
347
|
+
url.as_bytes(),
|
|
348
|
+
ip.as_bytes(),
|
|
349
|
+
request_id.as_bytes(),
|
|
350
|
+
&header_pairs,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
inner.handle_packed(&packed, body, out)
|
|
354
|
+
}
|
|
355
|
+
}
|