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,606 @@
|
|
|
1
|
+
// rust/http/accept.rs — Accept-Encoding parsing + content-coding negotiation.
|
|
2
|
+
//
|
|
3
|
+
// RFC 7231 §5.3.4: q-value parsing (0–1, up to 3 decimals), wildcard `*`,
|
|
4
|
+
// order preservation, and the `AcceptNegotiator` higher-order instance that
|
|
5
|
+
// precompiles the server's supported list once and answers "which encoding
|
|
6
|
+
// should I use for this Accept-Encoding header?"
|
|
7
|
+
|
|
8
|
+
use napi::bindgen_prelude::*;
|
|
9
|
+
use napi_derive::napi;
|
|
10
|
+
|
|
11
|
+
use crate::util::bytes::trim_ascii_whitespace;
|
|
12
|
+
|
|
13
|
+
/// One parsed Accept-Encoding entry (pure-Rust core type).
|
|
14
|
+
#[derive(Debug, Clone)]
|
|
15
|
+
pub struct EncodingPref {
|
|
16
|
+
pub encoding: String,
|
|
17
|
+
pub q: f32,
|
|
18
|
+
pub order: u32,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// Parse a q-value like `0.8`, `1`, `0.123`.
|
|
22
|
+
fn parse_q(input: &[u8]) -> Option<f32> {
|
|
23
|
+
let s = std::str::from_utf8(input).ok()?.trim();
|
|
24
|
+
if s.is_empty() {
|
|
25
|
+
return None;
|
|
26
|
+
}
|
|
27
|
+
s.parse::<f32>().ok().map(|v| v.clamp(0.0, 1.0))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Parse an Accept-Encoding header into ordered preferences (lowercased).
|
|
31
|
+
pub fn parse_accept_encoding_core(header: &[u8]) -> Vec<EncodingPref> {
|
|
32
|
+
let mut out = Vec::new();
|
|
33
|
+
let mut order = 0u32;
|
|
34
|
+
for part in header.split(|&b| b == b',') {
|
|
35
|
+
let part = trim_ascii_whitespace(part);
|
|
36
|
+
if part.is_empty() {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
let (name, q) = match part.iter().position(|&b| b == b';') {
|
|
40
|
+
Some(i) => {
|
|
41
|
+
let qpart = trim_ascii_whitespace(&part[i + 1..]);
|
|
42
|
+
let q = if let Some(eq) = qpart.iter().position(|&b| b == b'=') {
|
|
43
|
+
parse_q(&qpart[eq + 1..])
|
|
44
|
+
} else {
|
|
45
|
+
None
|
|
46
|
+
};
|
|
47
|
+
(trim_ascii_whitespace(&part[..i]), q.unwrap_or(1.0))
|
|
48
|
+
}
|
|
49
|
+
None => (part, 1.0),
|
|
50
|
+
};
|
|
51
|
+
if name.is_empty() {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
out.push(EncodingPref {
|
|
55
|
+
encoding: String::from_utf8_lossy(name).to_ascii_lowercase(),
|
|
56
|
+
q,
|
|
57
|
+
order,
|
|
58
|
+
});
|
|
59
|
+
order += 1;
|
|
60
|
+
}
|
|
61
|
+
out
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Borrowed form of `EncodingPref` — no per-entry String allocation.
|
|
65
|
+
#[derive(Clone, Copy)]
|
|
66
|
+
struct EncodingPrefRef<'a> {
|
|
67
|
+
name: &'a [u8],
|
|
68
|
+
q: f32,
|
|
69
|
+
order: u32,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/// Maximum encoding entries handled on the stack (covers every real-world
|
|
73
|
+
/// Accept-Encoding header). Headers with more entries fall back to the exact
|
|
74
|
+
/// heap path.
|
|
75
|
+
const MAX_STACK_PREFS: usize = 32;
|
|
76
|
+
|
|
77
|
+
/// Parse an Accept-Encoding header into borrowed prefs (no heap allocation).
|
|
78
|
+
/// Returns `usize::MAX` when the header has more than `out.len()` entries —
|
|
79
|
+
/// callers must fall back to the exact heap path in that case.
|
|
80
|
+
fn parse_accept_encoding_refs<'a>(header: &'a [u8], out: &mut [EncodingPrefRef<'a>]) -> usize {
|
|
81
|
+
let mut count = 0usize;
|
|
82
|
+
let mut order = 0u32;
|
|
83
|
+
for part in header.split(|&b| b == b',') {
|
|
84
|
+
let part = trim_ascii_whitespace(part);
|
|
85
|
+
if part.is_empty() {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let (name, q) = match part.iter().position(|&b| b == b';') {
|
|
89
|
+
Some(i) => {
|
|
90
|
+
let qpart = trim_ascii_whitespace(&part[i + 1..]);
|
|
91
|
+
let q = if let Some(eq) = qpart.iter().position(|&b| b == b'=') {
|
|
92
|
+
parse_q(&qpart[eq + 1..])
|
|
93
|
+
} else {
|
|
94
|
+
None
|
|
95
|
+
};
|
|
96
|
+
(trim_ascii_whitespace(&part[..i]), q.unwrap_or(1.0))
|
|
97
|
+
}
|
|
98
|
+
None => (part, 1.0),
|
|
99
|
+
};
|
|
100
|
+
if name.is_empty() {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if count >= out.len() {
|
|
104
|
+
return usize::MAX;
|
|
105
|
+
}
|
|
106
|
+
out[count] = EncodingPrefRef { name, q, order };
|
|
107
|
+
count += 1;
|
|
108
|
+
order += 1;
|
|
109
|
+
}
|
|
110
|
+
count
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// Equivalent to `pref_name.to_ascii_lowercase() == supported` WITHOUT
|
|
114
|
+
/// allocating — exactly mirrors the heap path, where the pref is lowercased
|
|
115
|
+
/// and the supported string is compared as-is (case-sensitive). For the usual
|
|
116
|
+
/// pre-lowercased supported lists this is a plain case-insensitive compare.
|
|
117
|
+
#[inline]
|
|
118
|
+
fn name_eq_supported(pref_name: &[u8], supported: &[u8]) -> bool {
|
|
119
|
+
if pref_name.len() != supported.len() {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
for (a, b) in pref_name.iter().zip(supported) {
|
|
123
|
+
if a.to_ascii_lowercase() != *b {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// Pick the best supported encoding for a header, or `None` for identity.
|
|
131
|
+
/// Priority: most specific match, then highest q, then earliest client order.
|
|
132
|
+
///
|
|
133
|
+
/// The common path parses the header into a stack buffer (no heap allocations
|
|
134
|
+
/// per call); a header with more than `MAX_STACK_PREFS` entries falls back to
|
|
135
|
+
/// the exact heap path.
|
|
136
|
+
pub fn negotiate_encoding(supported: &[String], header: &[u8]) -> Option<String> {
|
|
137
|
+
let mut stack = [EncodingPrefRef {
|
|
138
|
+
name: &[],
|
|
139
|
+
q: 0.0,
|
|
140
|
+
order: 0,
|
|
141
|
+
}; MAX_STACK_PREFS];
|
|
142
|
+
let count = parse_accept_encoding_refs(header, &mut stack);
|
|
143
|
+
if count == usize::MAX {
|
|
144
|
+
return negotiate_encoding_heap(supported, header);
|
|
145
|
+
}
|
|
146
|
+
if count == 0 {
|
|
147
|
+
return supported.first().cloned();
|
|
148
|
+
}
|
|
149
|
+
negotiate_refs(supported, &stack[..count])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/// Heap fallback with identical semantics to `negotiate_refs`, used only for
|
|
153
|
+
/// headers too large for the stack buffer.
|
|
154
|
+
fn negotiate_encoding_heap(supported: &[String], header: &[u8]) -> Option<String> {
|
|
155
|
+
let prefs = parse_accept_encoding_core(header);
|
|
156
|
+
if prefs.is_empty() {
|
|
157
|
+
return supported.first().cloned();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
let mut best: Option<(&String, f32, u32, u32)> = None; // (enc, q, spec, order)
|
|
161
|
+
for sup in supported {
|
|
162
|
+
let mut matched: Option<(f32, u32, u32)> = None; // (q, spec, order)
|
|
163
|
+
for pref in &prefs {
|
|
164
|
+
let spec = if pref.encoding == sup.as_str() {
|
|
165
|
+
2
|
|
166
|
+
} else if pref.encoding == "*" {
|
|
167
|
+
1
|
|
168
|
+
} else {
|
|
169
|
+
continue;
|
|
170
|
+
};
|
|
171
|
+
let replace = match matched {
|
|
172
|
+
None => true,
|
|
173
|
+
Some((_, s, o)) => spec > s || (spec == s && pref.order < o),
|
|
174
|
+
};
|
|
175
|
+
if replace {
|
|
176
|
+
matched = Some((pref.q, spec, pref.order));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
let Some((q, spec, order)) = matched else {
|
|
180
|
+
continue;
|
|
181
|
+
};
|
|
182
|
+
if q <= 0.0 {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
let cand = (sup, q, spec, order);
|
|
186
|
+
let better = match best {
|
|
187
|
+
None => true,
|
|
188
|
+
Some((_, bq, bspec, border)) => {
|
|
189
|
+
spec > bspec
|
|
190
|
+
|| (spec == bspec && (q - bq).abs() > 1e-4 && q > bq)
|
|
191
|
+
|| (spec == bspec && (q - bq).abs() <= 1e-4 && order < border)
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
if better {
|
|
195
|
+
best = Some(cand);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
best.map(|(e, _, _, _)| (*e).clone())
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/// Negotiation core over borrowed prefs (stack path).
|
|
202
|
+
fn negotiate_refs(supported: &[String], prefs: &[EncodingPrefRef<'_>]) -> Option<String> {
|
|
203
|
+
let mut best: Option<(&String, f32, u32, u32)> = None; // (enc, q, spec, order)
|
|
204
|
+
for sup in supported {
|
|
205
|
+
let mut matched: Option<(f32, u32, u32)> = None; // (q, spec, order)
|
|
206
|
+
for pref in prefs {
|
|
207
|
+
let spec = if name_eq_supported(pref.name, sup.as_bytes()) {
|
|
208
|
+
2
|
|
209
|
+
} else if pref.name == b"*" {
|
|
210
|
+
1
|
|
211
|
+
} else {
|
|
212
|
+
continue;
|
|
213
|
+
};
|
|
214
|
+
let replace = match matched {
|
|
215
|
+
None => true,
|
|
216
|
+
Some((_, s, o)) => spec > s || (spec == s && pref.order < o),
|
|
217
|
+
};
|
|
218
|
+
if replace {
|
|
219
|
+
matched = Some((pref.q, spec, pref.order));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
let Some((q, spec, order)) = matched else {
|
|
223
|
+
continue;
|
|
224
|
+
};
|
|
225
|
+
if q <= 0.0 {
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
let cand = (sup, q, spec, order);
|
|
229
|
+
let better = match best {
|
|
230
|
+
None => true,
|
|
231
|
+
Some((_, bq, bspec, border)) => {
|
|
232
|
+
spec > bspec
|
|
233
|
+
|| (spec == bspec && (q - bq).abs() > 1e-4 && q > bq)
|
|
234
|
+
|| (spec == bspec && (q - bq).abs() <= 1e-4 && order < border)
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
if better {
|
|
238
|
+
best = Some(cand);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
best.map(|(e, _, _, _)| (*e).clone())
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// ignex-compatible SERVER-preference negotiation (q-only, server order breaks
|
|
245
|
+
/// ties). This is the semantic of ignex's `negotiateEncoding` (compression
|
|
246
|
+
/// plugin): parse the header, apply explicit q-values with a wildcard
|
|
247
|
+
/// fallback for unlisted encodings, exclude `q <= 0`, and on a tie keep the
|
|
248
|
+
/// EARLIER supported entry (server preference) — NOT the client's order and
|
|
249
|
+
/// NOT the RFC-specificity rule. Empty/absent header → `None` (identity),
|
|
250
|
+
/// unlike the specificity negotiator which returns the first supported.
|
|
251
|
+
pub fn negotiate_encoding_server_preference(supported: &[String], header: &[u8]) -> Option<String> {
|
|
252
|
+
let mut stack = [EncodingPrefRef {
|
|
253
|
+
name: &[],
|
|
254
|
+
q: 0.0,
|
|
255
|
+
order: 0,
|
|
256
|
+
}; MAX_STACK_PREFS];
|
|
257
|
+
let count = parse_accept_encoding_refs(header, &mut stack);
|
|
258
|
+
if count == usize::MAX {
|
|
259
|
+
return negotiate_server_preference_heap(supported, header);
|
|
260
|
+
}
|
|
261
|
+
if count == 0 {
|
|
262
|
+
return None; // empty header → identity (no encoding preferred)
|
|
263
|
+
}
|
|
264
|
+
negotiate_server_preference_refs(supported, &stack[..count])
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// Server-preference negotiation core over borrowed prefs (stack path).
|
|
268
|
+
fn negotiate_server_preference_refs(
|
|
269
|
+
supported: &[String],
|
|
270
|
+
prefs: &[EncodingPrefRef<'_>],
|
|
271
|
+
) -> Option<String> {
|
|
272
|
+
// Wildcard q applies to every encoding not listed explicitly.
|
|
273
|
+
let mut wildcard_q = -1.0f32;
|
|
274
|
+
for p in prefs {
|
|
275
|
+
if p.name == b"*" {
|
|
276
|
+
wildcard_q = wildcard_q.max(p.q);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
let mut best: Option<(&String, f32)> = None; // (enc, q); ties keep the FIRST
|
|
280
|
+
for sup in supported {
|
|
281
|
+
// Explicit q for `sup` (first occurrence wins — RFC 7231 §5.3.4
|
|
282
|
+
// duplicates are undefined; ignex keeps the first).
|
|
283
|
+
let mut q = if wildcard_q >= 0.0 { wildcard_q } else { -1.0 };
|
|
284
|
+
for p in prefs {
|
|
285
|
+
if name_eq_supported(p.name, sup.as_bytes()) {
|
|
286
|
+
q = p.q;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if q <= 0.0 {
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
if best.is_none() || q > best.unwrap().1 {
|
|
294
|
+
best = Some((sup, q));
|
|
295
|
+
}
|
|
296
|
+
// Tie (q == best q) → keep the earlier supported entry (server pref).
|
|
297
|
+
}
|
|
298
|
+
best.map(|(e, _)| (*e).clone())
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Heap fallback for the server-preference negotiator (identical semantics).
|
|
302
|
+
fn negotiate_server_preference_heap(supported: &[String], header: &[u8]) -> Option<String> {
|
|
303
|
+
let prefs = parse_accept_encoding_core(header);
|
|
304
|
+
if prefs.is_empty() {
|
|
305
|
+
return None;
|
|
306
|
+
}
|
|
307
|
+
let mut wildcard_q = -1.0f32;
|
|
308
|
+
for p in &prefs {
|
|
309
|
+
if p.encoding == "*" {
|
|
310
|
+
wildcard_q = wildcard_q.max(p.q);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
let mut best: Option<(&String, f32)> = None;
|
|
314
|
+
for sup in supported {
|
|
315
|
+
let mut q = if wildcard_q >= 0.0 { wildcard_q } else { -1.0 };
|
|
316
|
+
for p in &prefs {
|
|
317
|
+
if p.encoding == sup.as_str() {
|
|
318
|
+
q = p.q;
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if q <= 0.0 {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if best.is_none() || q > best.unwrap().1 {
|
|
326
|
+
best = Some((sup, q));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
best.map(|(e, _)| (*e).clone())
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// napi-projected parse result.
|
|
333
|
+
#[napi(object)]
|
|
334
|
+
pub struct EncodingPrefResult {
|
|
335
|
+
pub encoding: String,
|
|
336
|
+
pub q: f64,
|
|
337
|
+
pub order: u32,
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#[napi]
|
|
341
|
+
pub fn parse_accept_encoding(input: Uint8Array) -> Vec<EncodingPrefResult> {
|
|
342
|
+
parse_accept_encoding_core(input.as_ref())
|
|
343
|
+
.into_iter()
|
|
344
|
+
.map(|p| EncodingPrefResult {
|
|
345
|
+
encoding: p.encoding,
|
|
346
|
+
q: f64::from(p.q),
|
|
347
|
+
order: p.order,
|
|
348
|
+
})
|
|
349
|
+
.collect()
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/// Higher-order instance: precompiles the supported encodings once and
|
|
353
|
+
/// negotiates any number of headers against that list.
|
|
354
|
+
#[napi]
|
|
355
|
+
pub struct AcceptNegotiator {
|
|
356
|
+
supported: Vec<String>,
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
#[napi]
|
|
360
|
+
impl AcceptNegotiator {
|
|
361
|
+
#[napi(constructor)]
|
|
362
|
+
pub fn new(supported: Vec<String>) -> Self {
|
|
363
|
+
let supported = supported
|
|
364
|
+
.into_iter()
|
|
365
|
+
.map(|s| s.to_ascii_lowercase())
|
|
366
|
+
.collect();
|
|
367
|
+
Self { supported }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// Best supported encoding for `header`, or null for identity.
|
|
371
|
+
#[napi]
|
|
372
|
+
pub fn negotiate(&self, header: Uint8Array) -> Option<String> {
|
|
373
|
+
negotiate_encoding(&self.supported, header.as_ref())
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Best supported encoding for `header` with SERVER-preference
|
|
377
|
+
/// tie-breaking (q-only; the supported list's order decides ties). Matches
|
|
378
|
+
/// ignex's `negotiateEncoding` semantics; empty header → None (identity).
|
|
379
|
+
#[napi]
|
|
380
|
+
pub fn negotiate_server_preference(&self, header: Uint8Array) -> Option<String> {
|
|
381
|
+
negotiate_encoding_server_preference(&self.supported, header.as_ref())
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/// Opaque handle to the precompiled supported list, for the `bun:ffi`
|
|
385
|
+
/// C-ABI fast path (`castrum_accept_negotiator_negotiate` in rust/ffi.rs).
|
|
386
|
+
/// Only valid while THIS instance is alive; the JS wrapper holds it.
|
|
387
|
+
#[napi]
|
|
388
|
+
pub fn inner_ptr(&self) -> u64 {
|
|
389
|
+
self as *const AcceptNegotiator as u64
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/// C-ABI support: best supported encoding for `header`, or None = identity.
|
|
394
|
+
///
|
|
395
|
+
/// # Safety
|
|
396
|
+
/// `p` must be a valid `*const AcceptNegotiator` from `inner_ptr`, alive for
|
|
397
|
+
/// the call (the JS wrapper holds the napi instance).
|
|
398
|
+
pub(crate) unsafe fn accept_negotiator_negotiate_core(
|
|
399
|
+
p: *const AcceptNegotiator,
|
|
400
|
+
header: &[u8],
|
|
401
|
+
) -> Option<Vec<u8>> {
|
|
402
|
+
let this = &*p;
|
|
403
|
+
negotiate_encoding(&this.supported, header).map(String::into_bytes)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// C-ABI support: server-preference negotiation (ignex `negotiateEncoding`
|
|
407
|
+
/// semantics). `None` = identity (no supported encoding acceptable / empty).
|
|
408
|
+
///
|
|
409
|
+
/// # Safety
|
|
410
|
+
/// `p` must be a valid `*const AcceptNegotiator` from `inner_ptr`, alive for
|
|
411
|
+
/// the call.
|
|
412
|
+
pub(crate) unsafe fn accept_negotiator_negotiate_server_core(
|
|
413
|
+
p: *const AcceptNegotiator,
|
|
414
|
+
header: &[u8],
|
|
415
|
+
) -> Option<Vec<u8>> {
|
|
416
|
+
let this = &*p;
|
|
417
|
+
negotiate_encoding_server_preference(&this.supported, header).map(String::into_bytes)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
#[cfg(test)]
|
|
421
|
+
mod tests {
|
|
422
|
+
use super::*;
|
|
423
|
+
|
|
424
|
+
fn negotiate(supported: &[&str], header: &str) -> Option<String> {
|
|
425
|
+
let sup: Vec<String> = supported.iter().map(|s| s.to_string()).collect();
|
|
426
|
+
negotiate_encoding(&sup, header.as_bytes())
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
#[test]
|
|
430
|
+
fn parses_q_and_order() {
|
|
431
|
+
let prefs = parse_accept_encoding_core(b"gzip;q=0.8, br, *;q=0.1");
|
|
432
|
+
assert_eq!(prefs.len(), 3);
|
|
433
|
+
assert_eq!(prefs[0].encoding, "gzip");
|
|
434
|
+
assert!((prefs[0].q - 0.8).abs() < 1e-4);
|
|
435
|
+
assert_eq!(prefs[0].order, 0);
|
|
436
|
+
assert_eq!(prefs[1].encoding, "br");
|
|
437
|
+
assert!((prefs[1].q - 1.0).abs() < 1e-4);
|
|
438
|
+
assert_eq!(prefs[2].encoding, "*");
|
|
439
|
+
assert!((prefs[2].q - 0.1).abs() < 1e-4);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
#[test]
|
|
443
|
+
fn lowercases_encoding() {
|
|
444
|
+
let prefs = parse_accept_encoding_core(b"GZip");
|
|
445
|
+
assert_eq!(prefs[0].encoding, "gzip");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
#[test]
|
|
449
|
+
fn empty_header_returns_first_supported() {
|
|
450
|
+
assert_eq!(negotiate(&["gzip", "br"], ""), Some("gzip".to_string()));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#[test]
|
|
454
|
+
fn picks_highest_q_among_explicit() {
|
|
455
|
+
assert_eq!(
|
|
456
|
+
negotiate(&["gzip", "br"], "gzip;q=0.8, br;q=1.0"),
|
|
457
|
+
Some("br".to_string())
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#[test]
|
|
462
|
+
fn specific_beats_wildcard_even_at_lower_q() {
|
|
463
|
+
assert_eq!(
|
|
464
|
+
negotiate(&["gzip", "br"], "gzip;q=0.5, *;q=1"),
|
|
465
|
+
Some("gzip".to_string())
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#[test]
|
|
470
|
+
fn q_zero_disables_even_with_wildcard() {
|
|
471
|
+
assert_eq!(negotiate(&["gzip"], "gzip;q=0, *;q=1"), None);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
#[test]
|
|
475
|
+
fn unlisted_without_wildcard_not_acceptable() {
|
|
476
|
+
assert_eq!(negotiate(&["gzip", "br"], "br;q=1"), Some("br".to_string()));
|
|
477
|
+
assert_eq!(negotiate(&["gzip"], "br;q=1"), None);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
#[test]
|
|
481
|
+
fn wildcard_matches_unlisted() {
|
|
482
|
+
assert_eq!(
|
|
483
|
+
negotiate(&["gzip"], "br;q=0.1, *;q=0.9"),
|
|
484
|
+
Some("gzip".to_string())
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#[test]
|
|
489
|
+
fn negotiator_instance_lowercases_supported() {
|
|
490
|
+
let n = AcceptNegotiator::new(vec!["GZip".to_string(), "Br".to_string()]);
|
|
491
|
+
assert_eq!(
|
|
492
|
+
n.negotiate(Uint8Array::new(b"br".to_vec())),
|
|
493
|
+
Some("br".to_string())
|
|
494
|
+
);
|
|
495
|
+
assert_eq!(
|
|
496
|
+
n.negotiate(Uint8Array::new(b"gzip".to_vec())),
|
|
497
|
+
Some("gzip".to_string())
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[test]
|
|
502
|
+
fn negotiate_stack_heap_parity() {
|
|
503
|
+
// The allocation-free stack path must produce IDENTICAL results to the
|
|
504
|
+
// exact heap path for every input, including case/whitespace/q-edge
|
|
505
|
+
// cases where the two implementations could drift.
|
|
506
|
+
let supported: Vec<String> = ["gzip", "br", "deflate"]
|
|
507
|
+
.iter()
|
|
508
|
+
.map(|s| s.to_string())
|
|
509
|
+
.collect();
|
|
510
|
+
let cases = [
|
|
511
|
+
"gzip, br, deflate",
|
|
512
|
+
"GZip;q=0.8, br;q=1.0",
|
|
513
|
+
"*;q=0.5",
|
|
514
|
+
"gzip;q=0, br;q=1",
|
|
515
|
+
"",
|
|
516
|
+
"zstd;q=1, *;q=0.1",
|
|
517
|
+
"gzip;q=0.2, gzip;q=0.9",
|
|
518
|
+
" br ;q = 0.5 ",
|
|
519
|
+
"gzip;q=0.9999, br;q=0.9998",
|
|
520
|
+
];
|
|
521
|
+
for c in cases {
|
|
522
|
+
assert_eq!(
|
|
523
|
+
negotiate_encoding(&supported, c.as_bytes()),
|
|
524
|
+
negotiate_encoding_heap(&supported, c.as_bytes()),
|
|
525
|
+
"stack vs heap parity for {c:?}"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[test]
|
|
531
|
+
fn negotiate_large_header_falls_back_to_heap() {
|
|
532
|
+
// A header with more than MAX_STACK_PREFS entries must exercise the
|
|
533
|
+
// heap fallback and still produce a correct result.
|
|
534
|
+
let mut header = String::new();
|
|
535
|
+
for i in 0..40 {
|
|
536
|
+
if i > 0 {
|
|
537
|
+
header.push(',');
|
|
538
|
+
}
|
|
539
|
+
header.push_str(&format!("enc{i};q=0.1"));
|
|
540
|
+
}
|
|
541
|
+
header.push_str(", gzip;q=1.0");
|
|
542
|
+
assert_eq!(
|
|
543
|
+
negotiate(&["gzip", "br"], &header),
|
|
544
|
+
Some("gzip".to_string())
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
#[test]
|
|
549
|
+
fn server_preference_matches_ignex_negotiate_encoding() {
|
|
550
|
+
let sup: Vec<String> = ["br", "gzip", "deflate"]
|
|
551
|
+
.iter()
|
|
552
|
+
.map(|s| s.to_string())
|
|
553
|
+
.collect();
|
|
554
|
+
let neg = |h: &str| negotiate_encoding_server_preference(&sup, h.as_bytes());
|
|
555
|
+
// The exact vectors from ignex content-encoding.ts `negotiateEncoding`.
|
|
556
|
+
assert_eq!(neg("gzip, br"), Some("br".to_string())); // tie → server pref (br first)
|
|
557
|
+
assert_eq!(neg("br;q=0.8, gzip;q=0.9"), Some("gzip".to_string()));
|
|
558
|
+
assert_eq!(neg("*"), Some("br".to_string()));
|
|
559
|
+
assert_eq!(neg("gzip;q=0, deflate"), Some("deflate".to_string()));
|
|
560
|
+
assert_eq!(neg("identity"), None);
|
|
561
|
+
assert_eq!(neg(""), None); // empty → identity
|
|
562
|
+
assert_eq!(
|
|
563
|
+
neg("deflate, gzip;q=0.5, br;q=0.3"),
|
|
564
|
+
Some("deflate".to_string())
|
|
565
|
+
);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
#[test]
|
|
569
|
+
fn server_preference_wildcard_vs_explicit() {
|
|
570
|
+
let sup: Vec<String> = ["gzip", "br"].iter().map(|s| s.to_string()).collect();
|
|
571
|
+
let neg = |h: &str| negotiate_encoding_server_preference(&sup, h.as_bytes());
|
|
572
|
+
// Explicit q=0.5 < wildcard q=1 → br (unlisted) wins (q-only, no specificity).
|
|
573
|
+
assert_eq!(neg("gzip;q=0.5, *;q=1"), Some("br".to_string()));
|
|
574
|
+
// Explicit q=1 beats wildcard q=0.5.
|
|
575
|
+
assert_eq!(neg("gzip;q=1, *;q=0.5"), Some("gzip".to_string()));
|
|
576
|
+
// Tie (both q=1) → FIRST supported entry wins (server preference):
|
|
577
|
+
// supported is ["gzip", "br"], so gzip wins regardless of client order.
|
|
578
|
+
assert_eq!(neg("br;q=1, gzip;q=1"), Some("gzip".to_string()));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
#[test]
|
|
582
|
+
fn server_preference_stack_heap_parity() {
|
|
583
|
+
let sup: Vec<String> = ["br", "gzip", "deflate"]
|
|
584
|
+
.iter()
|
|
585
|
+
.map(|s| s.to_string())
|
|
586
|
+
.collect();
|
|
587
|
+
let cases = [
|
|
588
|
+
"gzip, br, deflate",
|
|
589
|
+
"GZip;q=0.8, br;q=1.0",
|
|
590
|
+
"*;q=0.5",
|
|
591
|
+
"gzip;q=0, br;q=1",
|
|
592
|
+
"",
|
|
593
|
+
"zstd;q=1, *;q=0.1",
|
|
594
|
+
"gzip;q=0.2, gzip;q=0.9",
|
|
595
|
+
" br ;q = 0.5 ",
|
|
596
|
+
"gzip;q=0.9999, br;q=0.9998",
|
|
597
|
+
];
|
|
598
|
+
for c in cases {
|
|
599
|
+
assert_eq!(
|
|
600
|
+
negotiate_encoding_server_preference(&sup, c.as_bytes()),
|
|
601
|
+
negotiate_server_preference_heap(&sup, c.as_bytes()),
|
|
602
|
+
"server-pref stack vs heap parity for {c:?}"
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|