castrum 0.9.3 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2242 -0
- package/Cargo.toml +155 -0
- package/README.md +14 -6
- package/build.rs +5 -0
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/dist/index.d.ts +1508 -1291
- package/dist/index.js +3391 -2069
- package/docs/API.md +2 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +5 -3
- package/docs/ENVIRONMENT.md +25 -2
- package/docs/FFI_BUN_GUIDE.md +242 -14
- package/docs/HOW_TO_ADD_AN_OP.md +10 -0
- package/docs/REPO_MAP.md +17 -6
- package/docs/RND-CONCURRENCY.md +772 -0
- package/docs/bun-builtins-decision-matrix.md +4 -1
- package/index.ts +24 -25
- package/package.json +173 -156
- 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 +105 -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 +69 -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/task.rs +172 -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 +136 -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 +211 -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 +770 -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 +567 -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 +883 -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 +73 -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/task/completion.rs +119 -0
- package/rust/task/mod.rs +65 -0
- package/rust/task/ops.rs +503 -0
- package/rust/task/runtime.rs +220 -0
- package/rust/task/tests.rs +231 -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 +594 -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/health.ts +1 -2
- 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 +130 -12
- package/src/native/ffi/build/compress.ts +22 -10
- package/src/native/ffi/build/instances.ts +46 -4
- package/src/native/ffi/build/metrics.ts +247 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/task.ts +79 -0
- package/src/native/ffi/build/util.ts +32 -1
- package/src/native/ffi/build.ts +4 -0
- package/src/native/ffi/selftest.ts +12 -3
- package/src/native/ffi/types.ts +151 -9
- package/src/native/ffi.ts +160 -15
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +55 -5
- 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 +71 -27
- 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/rust-ffi/text.ts +25 -3
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +24 -4
- package/src/shared/codec.ts +18 -3
- package/src/task/index.ts +24 -0
- package/src/task/op.ts +171 -0
- package/src/task/runtime.ts +568 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// rust/util/bytes.rs — Shared low-level byte utilities
|
|
2
|
+
//
|
|
3
|
+
// This is the ONE home for the small byte-manipulation primitives that are
|
|
4
|
+
// reused across the crate: word-at-a-time comparison, hex (de)encoding,
|
|
5
|
+
// `%XX` percent decoding, whitespace trimming, and cookie pair splitting.
|
|
6
|
+
//
|
|
7
|
+
// Before the refactor these helpers were copy-pasted into several modules
|
|
8
|
+
// (headers.rs, method.rs, hmac_sha256.rs, random_token.rs, url_codec.rs,
|
|
9
|
+
// query_parser.rs, cookie_parser.rs, json_ser.rs). Keeping them in a single
|
|
10
|
+
// place means there is exactly ONE implementation to read, test, and tune.
|
|
11
|
+
|
|
12
|
+
// ── Word-at-a-time comparison ──────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/// Load a `u64` from the first `min(len, 8)` bytes of `bytes`, zero-padded.
|
|
15
|
+
#[inline(always)]
|
|
16
|
+
pub fn load_u64_padded(bytes: &[u8]) -> u64 {
|
|
17
|
+
let mut buf = [0u8; 8];
|
|
18
|
+
let len = bytes.len().min(8);
|
|
19
|
+
buf[..len].copy_from_slice(&bytes[..len]);
|
|
20
|
+
u64::from_le_bytes(buf)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// ASCII case-insensitive comparison using u64 words for short inputs.
|
|
24
|
+
///
|
|
25
|
+
/// Both inputs must have the same length. The `0x20` bit is OR'd into both
|
|
26
|
+
/// words so `A`..`Z` and `a`..`z` collapse to the same value — no allocation.
|
|
27
|
+
///
|
|
28
|
+
/// Note: only safe for ASCII name matching (headers, HTTP methods), never for
|
|
29
|
+
/// arbitrary binary data (the `0x20` mask can equate some non-alpha chars).
|
|
30
|
+
#[inline(always)]
|
|
31
|
+
pub fn ascii_eq_ignore_case(a: &[u8], b: &[u8]) -> bool {
|
|
32
|
+
if a.len() != b.len() {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
match a.len() {
|
|
37
|
+
0 => true,
|
|
38
|
+
1..=8 => {
|
|
39
|
+
let wa = load_u64_padded(a) | 0x2020_2020_2020_2020;
|
|
40
|
+
let wb = load_u64_padded(b) | 0x2020_2020_2020_2020;
|
|
41
|
+
wa == wb
|
|
42
|
+
}
|
|
43
|
+
9..=16 => {
|
|
44
|
+
let wa = load_u64_padded(a) | 0x2020_2020_2020_2020;
|
|
45
|
+
let wb = load_u64_padded(b) | 0x2020_2020_2020_2020;
|
|
46
|
+
if wa != wb {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
let wa2 = load_u64_padded(&a[8..]) | 0x2020_2020_2020_2020;
|
|
50
|
+
let wb2 = load_u64_padded(&b[8..]) | 0x2020_2020_2020_2020;
|
|
51
|
+
wa2 == wb2
|
|
52
|
+
}
|
|
53
|
+
_ => a.eq_ignore_ascii_case(b),
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Hex (de)encoding ───────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
pub const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
|
|
60
|
+
pub const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF";
|
|
61
|
+
|
|
62
|
+
/// 256-entry hex-digit lookup (single table load instead of a range-compare
|
|
63
|
+
/// chain); -1 marks a non-hex byte. Shared by all `%XX` / hex decode paths.
|
|
64
|
+
const HEX_VAL_LUT: [i8; 256] = {
|
|
65
|
+
let mut t = [-1i8; 256];
|
|
66
|
+
let mut i = 0usize;
|
|
67
|
+
while i < 256 {
|
|
68
|
+
let b = i as u8;
|
|
69
|
+
t[i] = match b {
|
|
70
|
+
b'0'..=b'9' => (b - b'0') as i8,
|
|
71
|
+
b'a'..=b'f' => (b - b'a' + 10) as i8,
|
|
72
|
+
b'A'..=b'F' => (b - b'A' + 10) as i8,
|
|
73
|
+
_ => -1,
|
|
74
|
+
};
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
t
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/// Value of a single hex digit, or `None` if `b` is not a hex digit.
|
|
81
|
+
#[inline(always)]
|
|
82
|
+
pub fn hex_val(b: u8) -> Option<u8> {
|
|
83
|
+
let v = HEX_VAL_LUT[b as usize];
|
|
84
|
+
if v < 0 {
|
|
85
|
+
None
|
|
86
|
+
} else {
|
|
87
|
+
Some(v as u8)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// Encode `bytes` as lowercase hex into `out`, which must hold `2 * bytes.len()`.
|
|
92
|
+
#[inline]
|
|
93
|
+
pub fn hex_encode(bytes: &[u8], out: &mut [u8]) {
|
|
94
|
+
// Checked (not debug-only): with `debug-assertions = false` an undersized
|
|
95
|
+
// `out` would otherwise surface as an unhelpful index panic in release.
|
|
96
|
+
assert!(
|
|
97
|
+
out.len() >= bytes.len() * 2,
|
|
98
|
+
"hex_encode: output buffer too small ({} < {})",
|
|
99
|
+
out.len(),
|
|
100
|
+
bytes.len() * 2
|
|
101
|
+
);
|
|
102
|
+
for (i, &b) in bytes.iter().enumerate() {
|
|
103
|
+
out[2 * i] = HEX_LOWER[(b >> 4) as usize];
|
|
104
|
+
out[2 * i + 1] = HEX_LOWER[(b & 0x0f) as usize];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/// Encode exactly 32 bytes as lowercase hex into a fixed 64-byte output.
|
|
109
|
+
#[inline(always)]
|
|
110
|
+
pub fn hex_encode_32(bytes: &[u8], out: &mut [u8; 64]) {
|
|
111
|
+
debug_assert_eq!(bytes.len(), 32);
|
|
112
|
+
hex_encode(bytes, out);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/// Decode exactly 64 hex characters into 32 bytes. Returns `None` on bad input.
|
|
116
|
+
#[inline]
|
|
117
|
+
pub fn hex_decode_32(hex: &[u8]) -> Option<[u8; 32]> {
|
|
118
|
+
if hex.len() != 64 {
|
|
119
|
+
return None;
|
|
120
|
+
}
|
|
121
|
+
let mut out = [0u8; 32];
|
|
122
|
+
for (i, slot) in out.iter_mut().enumerate() {
|
|
123
|
+
let hi = hex_val(hex[2 * i])?;
|
|
124
|
+
let lo = hex_val(hex[2 * i + 1])?;
|
|
125
|
+
*slot = (hi << 4) | lo;
|
|
126
|
+
}
|
|
127
|
+
Some(out)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Percent decoding ───────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/// Decode the `%XX` sequence starting at `src[i..]` into a single byte.
|
|
133
|
+
///
|
|
134
|
+
/// Returns `(decoded_byte, index_of_next_input_byte)`, or `None` when the
|
|
135
|
+
/// sequence is malformed (truncated or a non-hex digit).
|
|
136
|
+
#[inline]
|
|
137
|
+
pub fn decode_percent_at(src: &[u8], i: usize) -> Option<(u8, usize)> {
|
|
138
|
+
if i + 2 >= src.len() {
|
|
139
|
+
return None;
|
|
140
|
+
}
|
|
141
|
+
let hi = hex_val(src[i + 1])?;
|
|
142
|
+
let lo = hex_val(src[i + 2])?;
|
|
143
|
+
Some(((hi << 4) | lo, i + 3))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Form-component decoding ────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/// Decode failure modes for the form-component decoders.
|
|
149
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
150
|
+
pub enum FormDecodeError {
|
|
151
|
+
/// Malformed `%XX` (truncated / non-hex digit) or a decoded byte sequence
|
|
152
|
+
/// that is not valid UTF-8 — exactly what JS's `decodeURIComponent` throws
|
|
153
|
+
/// `URIError` on. Reported by [`decode_form_component_strict_into`]; the
|
|
154
|
+
/// lenient [`decode_form_component_into`] answers with the RAW component
|
|
155
|
+
/// instead (JS's `catch` arm).
|
|
156
|
+
Malformed,
|
|
157
|
+
/// `out` cannot hold the decoded result.
|
|
158
|
+
BufferTooSmall,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/// SIMD-accelerated UTF-8 validation (the crate `http::url_codec` already uses,
|
|
162
|
+
/// so the whole HTTP surface validates with the same implementation).
|
|
163
|
+
#[inline]
|
|
164
|
+
fn is_valid_utf8(bytes: &[u8]) -> bool {
|
|
165
|
+
simdutf8::basic::from_utf8(bytes).is_ok()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// THE form-component decoder: `+` → space, `%XX` → byte, bulk-copying plain
|
|
169
|
+
/// runs between them, and validating the result's UTF-8 shape.
|
|
170
|
+
///
|
|
171
|
+
/// This is the single place URL-form decoding happens. Its callers differ only
|
|
172
|
+
/// in their FAILURE ARM:
|
|
173
|
+
///
|
|
174
|
+
/// * [`decode_form_component_into`] — lenient, JS `decodeURIComponent`
|
|
175
|
+
/// semantics: `try { decodeURIComponent(s.replace(/\+/g, " ")) } catch { return s }`.
|
|
176
|
+
/// Used by the packed pair parsers (query/form) and the native route stack,
|
|
177
|
+
/// which must answer exactly like the pure-TS fallback.
|
|
178
|
+
/// * [`decode_form_component_strict_into`] — `Malformed` is a real error, for
|
|
179
|
+
/// callers that map a bad component to a 400 (query → JSON).
|
|
180
|
+
///
|
|
181
|
+
/// Keeping one core means the two can never drift apart again — which is how
|
|
182
|
+
/// the packed parser ended up erroring on input the JS fallback happily
|
|
183
|
+
/// returned raw.
|
|
184
|
+
///
|
|
185
|
+
/// `Malformed` is exactly the set JS rejects: a truncated/non-hex `%XX`, and a
|
|
186
|
+
/// decoded sequence that is not valid UTF-8 (overlong encodings, surrogate
|
|
187
|
+
/// halves and > U+10FFFF fail `simdutf8` and `decodeURIComponent` alike).
|
|
188
|
+
///
|
|
189
|
+
/// The decoded length never exceeds `src.len()`, so a caller that sizes `out`
|
|
190
|
+
/// to the input can only observe [`FormDecodeError::BufferTooSmall`] when it
|
|
191
|
+
/// passed a smaller tail of a larger buffer.
|
|
192
|
+
#[inline]
|
|
193
|
+
fn decode_form_core(src: &[u8], out: &mut [u8]) -> std::result::Result<usize, FormDecodeError> {
|
|
194
|
+
// Nothing to decode at all (the overwhelming majority of components): a
|
|
195
|
+
// verbatim copy. No UTF-8 check — an undecoded component is passed through
|
|
196
|
+
// byte-for-byte, exactly like the JS fallback's `indexOf` early return.
|
|
197
|
+
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
198
|
+
if out.len() < src.len() {
|
|
199
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
200
|
+
}
|
|
201
|
+
out[..src.len()].copy_from_slice(src);
|
|
202
|
+
return Ok(src.len());
|
|
203
|
+
}
|
|
204
|
+
// Run-copy decode: bulk-copy plain runs up to the next '+'/'%', then
|
|
205
|
+
// decode the single special byte, instead of walking every byte. This is
|
|
206
|
+
// the same memchr2 discipline `url_decode` uses and avoids per-byte
|
|
207
|
+
// branches over the common long runs of unreserved characters. Capacity
|
|
208
|
+
// is still checked against the ACTUAL decoded length (a `%XX` shrinks 3
|
|
209
|
+
// input bytes to 1), preserving the existing semantics.
|
|
210
|
+
let mut i = 0usize;
|
|
211
|
+
let mut written = 0usize;
|
|
212
|
+
// ASCII output is trivially valid UTF-8, so the validator is only paid when
|
|
213
|
+
// a high byte actually reaches the result (same discipline as `url_codec`).
|
|
214
|
+
let mut saw_high = false;
|
|
215
|
+
while i < src.len() {
|
|
216
|
+
match memchr::memchr2(b'+', b'%', &src[i..]) {
|
|
217
|
+
Some(rel) => {
|
|
218
|
+
let run_end = i + rel;
|
|
219
|
+
let run = &src[i..run_end];
|
|
220
|
+
if written + run.len() > out.len() {
|
|
221
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
222
|
+
}
|
|
223
|
+
out[written..written + run.len()].copy_from_slice(run);
|
|
224
|
+
saw_high |= !run.is_ascii();
|
|
225
|
+
written += run.len();
|
|
226
|
+
i = run_end;
|
|
227
|
+
|
|
228
|
+
// Decode the '+' or '%XX' at position i.
|
|
229
|
+
let (b, next) = match src[i] {
|
|
230
|
+
b'+' => (b' ', i + 1),
|
|
231
|
+
_ => {
|
|
232
|
+
let (byte, next) =
|
|
233
|
+
decode_percent_at(src, i).ok_or(FormDecodeError::Malformed)?;
|
|
234
|
+
(byte, next)
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
if written >= out.len() {
|
|
238
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
239
|
+
}
|
|
240
|
+
out[written] = b;
|
|
241
|
+
saw_high |= b >= 0x80;
|
|
242
|
+
written += 1;
|
|
243
|
+
i = next;
|
|
244
|
+
}
|
|
245
|
+
None => {
|
|
246
|
+
let run = &src[i..];
|
|
247
|
+
if written + run.len() > out.len() {
|
|
248
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
249
|
+
}
|
|
250
|
+
out[written..written + run.len()].copy_from_slice(run);
|
|
251
|
+
saw_high |= !run.is_ascii();
|
|
252
|
+
written += run.len();
|
|
253
|
+
i = src.len();
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if saw_high && !is_valid_utf8(&out[..written]) {
|
|
258
|
+
return Err(FormDecodeError::Malformed);
|
|
259
|
+
}
|
|
260
|
+
Ok(written)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/// Copy `src` verbatim into `out` — the raw fallback of the lenient contract.
|
|
264
|
+
#[inline]
|
|
265
|
+
fn write_raw_component(src: &[u8], out: &mut [u8]) -> std::result::Result<usize, FormDecodeError> {
|
|
266
|
+
if out.len() < src.len() {
|
|
267
|
+
return Err(FormDecodeError::BufferTooSmall);
|
|
268
|
+
}
|
|
269
|
+
out[..src.len()].copy_from_slice(src);
|
|
270
|
+
Ok(src.len())
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// URL-decode a form component (`+` → space, `%XX` → byte) into `out` with
|
|
274
|
+
/// **JS `decodeURIComponent` fallback semantics**, returning bytes written.
|
|
275
|
+
///
|
|
276
|
+
/// Per COMPONENT (name and value decode independently):
|
|
277
|
+
///
|
|
278
|
+
/// 1. no `%` and no `+` → copied verbatim;
|
|
279
|
+
/// 2. otherwise `+` → space and `%XX` → byte;
|
|
280
|
+
/// 3. a malformed escape (truncated or non-hex) → the WHOLE component is
|
|
281
|
+
/// returned RAW — note `+` stays `+`, because JS's `catch` returns the
|
|
282
|
+
/// string it was given, before the `+` replacement;
|
|
283
|
+
/// 4. a decoded sequence that is not valid UTF-8 → the whole component RAW.
|
|
284
|
+
///
|
|
285
|
+
/// That is `try { decodeURIComponent(s.replace(/\+/g, " ")) } catch { return s }`
|
|
286
|
+
/// — the pure-TS fallback swallows the `URIError` and the native path must too,
|
|
287
|
+
/// or a single bad escape turns a public request into a 500 (and a divergent
|
|
288
|
+
/// parse) instead of a raw field.
|
|
289
|
+
#[inline]
|
|
290
|
+
pub fn decode_form_component_into(
|
|
291
|
+
src: &[u8],
|
|
292
|
+
out: &mut [u8],
|
|
293
|
+
) -> std::result::Result<usize, FormDecodeError> {
|
|
294
|
+
match decode_form_core(src, out) {
|
|
295
|
+
Ok(written) => Ok(written),
|
|
296
|
+
Err(FormDecodeError::Malformed) => write_raw_component(src, out),
|
|
297
|
+
Err(e) => Err(e),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// Compute the decoded length of `src` WITHOUT writing — the exact-size pass for
|
|
302
|
+
/// the C-ABI "needed size" convention (run ONCE on a buffer miss, so the caller
|
|
303
|
+
/// can `growExact` and retry instead of a doubling loop).
|
|
304
|
+
///
|
|
305
|
+
/// Reports [`FormDecodeError::Malformed`] exactly when the writer would (the
|
|
306
|
+
/// raw fallback reports `src.len()`), so the reported size and the written size
|
|
307
|
+
/// can never disagree — a mismatch would make the caller's grow-and-retry loop
|
|
308
|
+
/// spin forever.
|
|
309
|
+
///
|
|
310
|
+
/// One conservative case is deliberate: when the decoded output contains a
|
|
311
|
+
/// non-ASCII byte, the result MAY be invalid UTF-8 and the writer would then
|
|
312
|
+
/// emit the raw component (longer than the decoded form), so `src.len()` is
|
|
313
|
+
/// reported. Over-reporting only costs the caller a few buffer bytes (the C fn
|
|
314
|
+
/// still returns the true written length); under-reporting would hang.
|
|
315
|
+
#[inline]
|
|
316
|
+
pub fn decode_form_component_len(src: &[u8]) -> usize {
|
|
317
|
+
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
318
|
+
return src.len();
|
|
319
|
+
}
|
|
320
|
+
let mut i = 0usize;
|
|
321
|
+
let mut written = 0usize;
|
|
322
|
+
let mut saw_high = false;
|
|
323
|
+
while i < src.len() {
|
|
324
|
+
match memchr::memchr2(b'+', b'%', &src[i..]) {
|
|
325
|
+
Some(rel) => {
|
|
326
|
+
let run_end = i + rel;
|
|
327
|
+
written += rel;
|
|
328
|
+
saw_high |= !src[i..run_end].is_ascii();
|
|
329
|
+
i = run_end;
|
|
330
|
+
match src[i] {
|
|
331
|
+
b'+' => {
|
|
332
|
+
written += 1;
|
|
333
|
+
i += 1;
|
|
334
|
+
}
|
|
335
|
+
_ => match decode_percent_at(src, i) {
|
|
336
|
+
Some((byte, next)) => {
|
|
337
|
+
written += 1;
|
|
338
|
+
saw_high |= byte >= 0x80;
|
|
339
|
+
i = next;
|
|
340
|
+
}
|
|
341
|
+
// Malformed → the writer emits the raw component, so
|
|
342
|
+
// the raw length is the exact answer here.
|
|
343
|
+
None => return src.len(),
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
None => {
|
|
348
|
+
written += src.len() - i;
|
|
349
|
+
saw_high |= !src[i..].is_ascii();
|
|
350
|
+
i = src.len();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if saw_high {
|
|
355
|
+
src.len()
|
|
356
|
+
} else {
|
|
357
|
+
written
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/// Decode a form component into a reusable scratch buffer, returning the
|
|
362
|
+
/// decoded bytes — or `src` itself when nothing needed decoding. The shared
|
|
363
|
+
/// entry point for Vec-based callers (the native route stack); the returned
|
|
364
|
+
/// slice borrows either `src` or `scratch`, so it is valid only until the next
|
|
365
|
+
/// call with the same scratch.
|
|
366
|
+
#[inline]
|
|
367
|
+
pub fn decode_form_component_scratch<'a>(src: &'a [u8], scratch: &'a mut Vec<u8>) -> &'a [u8] {
|
|
368
|
+
// Fast path: nothing to decode (the overwhelming majority of segments).
|
|
369
|
+
if memchr::memchr2(b'+', b'%', src).is_none() {
|
|
370
|
+
return src;
|
|
371
|
+
}
|
|
372
|
+
if scratch.len() < src.len() {
|
|
373
|
+
scratch.resize(src.len(), 0);
|
|
374
|
+
}
|
|
375
|
+
match decode_form_component_into(src, scratch.as_mut_slice()) {
|
|
376
|
+
Ok(n) => &scratch[..n],
|
|
377
|
+
// Unreachable: the buffer is input-sized, and the decoded length never
|
|
378
|
+
// exceeds the input.
|
|
379
|
+
Err(_) => src,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ── Whitespace + cookie splitting ──────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/// Trim ASCII whitespace from both ends of a byte slice.
|
|
386
|
+
#[inline(always)]
|
|
387
|
+
pub fn trim_ascii_whitespace(bytes: &[u8]) -> &[u8] {
|
|
388
|
+
let mut start = 0usize;
|
|
389
|
+
let mut end = bytes.len();
|
|
390
|
+
|
|
391
|
+
while start < end && bytes[start].is_ascii_whitespace() {
|
|
392
|
+
start += 1;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
while end > start && bytes[end - 1].is_ascii_whitespace() {
|
|
396
|
+
end -= 1;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
&bytes[start..end]
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/// Iterate over the `name=value` pairs in a cookie-style header.
|
|
403
|
+
///
|
|
404
|
+
/// Pairs are split on `;`, whitespace-trimmed, and entries with an empty name
|
|
405
|
+
/// are skipped. A missing `=` yields an empty value. A value wrapped in
|
|
406
|
+
/// surrounding double quotes is unwrapped (RFC 6265 §5.2) so the native
|
|
407
|
+
/// output is byte-identical to the JS fallback.
|
|
408
|
+
pub fn cookie_pairs(input: &[u8]) -> impl Iterator<Item = (&[u8], &[u8])> + '_ {
|
|
409
|
+
input.split(|&b| b == b';').filter_map(|raw| {
|
|
410
|
+
let pair = trim_ascii_whitespace(raw);
|
|
411
|
+
if pair.is_empty() {
|
|
412
|
+
return None;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
let (name, value) = match pair.iter().position(|&b| b == b'=') {
|
|
416
|
+
Some(eq) => (&pair[..eq], &pair[eq + 1..]),
|
|
417
|
+
None => (pair, &[][..]),
|
|
418
|
+
};
|
|
419
|
+
|
|
420
|
+
let name = trim_ascii_whitespace(name);
|
|
421
|
+
let value = trim_ascii_whitespace(value);
|
|
422
|
+
|
|
423
|
+
if name.is_empty() {
|
|
424
|
+
return None;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// RFC 6265 §5.2: unwrap a cookie-value surrounded by DQUOTE (only when
|
|
428
|
+
// BOTH ends quote, matching the JS fallback's `unquote` exactly).
|
|
429
|
+
let value = if value.len() >= 2 && value[0] == b'"' && value[value.len() - 1] == b'"' {
|
|
430
|
+
&value[1..value.len() - 1]
|
|
431
|
+
} else {
|
|
432
|
+
value
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
Some((name, value))
|
|
436
|
+
})
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#[cfg(test)]
|
|
440
|
+
mod tests {
|
|
441
|
+
use super::*;
|
|
442
|
+
|
|
443
|
+
fn decode(src: &[u8]) -> Vec<u8> {
|
|
444
|
+
let mut out = vec![0u8; src.len()];
|
|
445
|
+
let n = decode_form_component_into(src, &mut out).unwrap();
|
|
446
|
+
out.truncate(n);
|
|
447
|
+
out
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
#[test]
|
|
451
|
+
fn decode_form_plain_passthrough() {
|
|
452
|
+
assert_eq!(decode(b"abc"), b"abc");
|
|
453
|
+
assert_eq!(decode(b""), b"");
|
|
454
|
+
// The fast path copies exactly; verify capacity via the full slice.
|
|
455
|
+
let mut out = [0u8; 3];
|
|
456
|
+
assert_eq!(decode_form_component_into(b"abc", &mut out), Ok(3));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
#[test]
|
|
460
|
+
fn decode_form_plus_and_percent() {
|
|
461
|
+
assert_eq!(decode(b"a+b"), b"a b");
|
|
462
|
+
assert_eq!(decode(b"%41%42"), b"AB");
|
|
463
|
+
assert_eq!(decode(b"q=%E2%9C%93"), b"q=\xE2\x9C\x93");
|
|
464
|
+
assert_eq!(decode(b"%2B"), b"+");
|
|
465
|
+
// A NUL is a legal decode target (U+0000), not an error.
|
|
466
|
+
assert_eq!(decode(b"%00"), b"\x00");
|
|
467
|
+
assert_eq!(decode(b"%2f"), b"/");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#[test]
|
|
471
|
+
fn decode_form_decodeuri_semantics_table() {
|
|
472
|
+
// The contract is `try { decodeURIComponent(s.replace(/\+/g," ")) }
|
|
473
|
+
// catch { return s }` — verified against Bun's `decodeURIComponent`.
|
|
474
|
+
let cases: &[(&[u8], &[u8])] = &[
|
|
475
|
+
// (input, JS result)
|
|
476
|
+
(b"a=%C3", b"a=%C3"), // truncated multibyte → URIError → raw
|
|
477
|
+
(b"a=%2", b"a=%2"), // truncated escape → raw
|
|
478
|
+
(b"a=%", b"a=%"), // dangling % → raw
|
|
479
|
+
(b"a=%2G", b"a=%2G"), // non-hex digit → raw
|
|
480
|
+
(b"a=%FF", b"a=%FF"), // invalid UTF-8 byte → raw
|
|
481
|
+
(b"a=%ED%A0%80", b"a=%ED%A0%80"), // surrogate half → raw
|
|
482
|
+
(b"a=%C0%80", b"a=%C0%80"), // overlong encoding → raw
|
|
483
|
+
(b"a=%F4%90%80%80", b"a=%F4%90%80%80"), // > U+10FFFF → raw
|
|
484
|
+
(b"bad=%ZZ", b"bad=%ZZ"), // non-hex → raw
|
|
485
|
+
(b"100%", b"100%"), // trailing % → raw
|
|
486
|
+
// The whole component goes raw — including a '+' that the decode
|
|
487
|
+
// would otherwise have turned into a space (the JS catch returns
|
|
488
|
+
// the string it was given, before the replace).
|
|
489
|
+
(b"a+b=%ZZ", b"a+b=%ZZ"),
|
|
490
|
+
(b"a+b=%20", b"a b= "), // valid → decoded
|
|
491
|
+
(b"%C3%A9", b"\xC3\xA9"), // valid multibyte → decoded
|
|
492
|
+
];
|
|
493
|
+
for (input, expected) in cases {
|
|
494
|
+
assert_eq!(
|
|
495
|
+
decode(input).as_slice(),
|
|
496
|
+
*expected,
|
|
497
|
+
"lenient decode of {:?}",
|
|
498
|
+
core::str::from_utf8(input).unwrap_or("<non-utf8>")
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
#[test]
|
|
504
|
+
fn decode_form_needed_size_matches_written_size() {
|
|
505
|
+
// The C-ABI "needed size" pass must equal what the writer produces for
|
|
506
|
+
// EVERY input — a mismatch would spin the caller's grow-and-retry loop.
|
|
507
|
+
// It may only over-report (never under-report) when the decoded form
|
|
508
|
+
// could be invalid UTF-8.
|
|
509
|
+
let cases: &[&[u8]] = &[
|
|
510
|
+
b"",
|
|
511
|
+
b"a",
|
|
512
|
+
b"a=1&b=2",
|
|
513
|
+
b"%ZZ",
|
|
514
|
+
b"a=%C3",
|
|
515
|
+
b"a=%FF",
|
|
516
|
+
b"a=%ED%A0%80",
|
|
517
|
+
b"q=hello+world",
|
|
518
|
+
b"u=%E2%9C%93",
|
|
519
|
+
b"\xFF\xFE", // non-UTF-8 passthrough
|
|
520
|
+
b"a=%E2%9C%93&b%ZZ=1",
|
|
521
|
+
b"x%",
|
|
522
|
+
];
|
|
523
|
+
for src in cases {
|
|
524
|
+
let mut out = vec![0u8; src.len() + 16];
|
|
525
|
+
let written = decode_form_component_into(src, &mut out).unwrap();
|
|
526
|
+
let reported = decode_form_component_len(src);
|
|
527
|
+
assert!(
|
|
528
|
+
reported >= written,
|
|
529
|
+
"reported {reported} < written {written} for {:?}",
|
|
530
|
+
core::str::from_utf8(src).unwrap_or("<non-utf8>")
|
|
531
|
+
);
|
|
532
|
+
// An exact-size buffer must succeed and agree with the report.
|
|
533
|
+
let mut exact = vec![0u8; reported];
|
|
534
|
+
assert_eq!(
|
|
535
|
+
decode_form_component_into(src, &mut exact).unwrap(),
|
|
536
|
+
written,
|
|
537
|
+
"exact-size decode disagreed for {:?}",
|
|
538
|
+
core::str::from_utf8(src).unwrap_or("<non-utf8>")
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
#[test]
|
|
544
|
+
fn decode_form_scratch_matches_slice_decoder() {
|
|
545
|
+
let mut scratch: Vec<u8> = Vec::new();
|
|
546
|
+
for input in [
|
|
547
|
+
&b"plain"[..],
|
|
548
|
+
b"a+b",
|
|
549
|
+
b"a=%20b",
|
|
550
|
+
b"%ZZ",
|
|
551
|
+
b"%C3",
|
|
552
|
+
b"a=%E2%9C%93",
|
|
553
|
+
] {
|
|
554
|
+
let via_scratch = decode_form_component_scratch(input, &mut scratch).to_vec();
|
|
555
|
+
assert_eq!(via_scratch.as_slice(), decode(input).as_slice());
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
#[test]
|
|
560
|
+
fn cookie_pairs_unwraps_dquote() {
|
|
561
|
+
let pairs: Vec<(&[u8], &[u8])> =
|
|
562
|
+
cookie_pairs(b"a=1; b=\"quoted value\"; c=\"\"; d=\"abc; empty=").collect();
|
|
563
|
+
assert_eq!(
|
|
564
|
+
pairs,
|
|
565
|
+
vec![
|
|
566
|
+
(&b"a"[..], &b"1"[..]),
|
|
567
|
+
(&b"b"[..], &b"quoted value"[..]),
|
|
568
|
+
(&b"c"[..], &b""[..]),
|
|
569
|
+
(&b"d"[..], &b"\"abc"[..]), // unbalanced quote kept, matches JS
|
|
570
|
+
(&b"empty"[..], &b""[..]),
|
|
571
|
+
]
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
#[test]
|
|
576
|
+
fn cookie_pairs_keeps_unbalanced_quotes() {
|
|
577
|
+
// A quote only at one end (or embedded) is left as-is — matches JS.
|
|
578
|
+
let pairs: Vec<(&[u8], &[u8])> = cookie_pairs(b"a=\"abc; b=ab\"c; c=\"quote").collect();
|
|
579
|
+
assert_eq!(
|
|
580
|
+
pairs,
|
|
581
|
+
vec![
|
|
582
|
+
(&b"a"[..], &b"\"abc"[..]),
|
|
583
|
+
(&b"b"[..], &b"ab\"c"[..]),
|
|
584
|
+
(&b"c"[..], &b"\"quote"[..]),
|
|
585
|
+
]
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
#[test]
|
|
589
|
+
#[should_panic(expected = "hex_encode: output buffer too small")]
|
|
590
|
+
fn hex_encode_panics_on_undersized() {
|
|
591
|
+
let mut out = [0u8; 4];
|
|
592
|
+
super::hex_encode(b"abcd", &mut out);
|
|
593
|
+
}
|
|
594
|
+
}
|
package/rust/util/mod.rs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
//! Shared infrastructure (no napi types in core signatures).
|
|
2
|
+
//
|
|
3
|
+
// Task-focused modules (no napi types in core signatures):
|
|
4
|
+
// - bytes.rs byte primitives (word-compare, hex, %XX decode, trim, cookie_pairs)
|
|
5
|
+
// - packed.rs zero-alloc packed iterators + byte writers
|
|
6
|
+
// - batch/ packed aggregate batch helpers (core.rs routing + api.rs napi)
|
|
7
|
+
// - batch_core.rs generic rayon-parallel batch helpers
|
|
8
|
+
// - threadpool.rs rayon global pool init + parallelism heuristics
|
|
9
|
+
// - validation.rs email / UUID / IPv4 / IPv6 + batch fixed-width hex validators
|
|
10
|
+
// - text.rs text utilities (JS-RegExp metacharacter escaping)
|
|
11
|
+
//
|
|
12
|
+
// The `pub use` re-exports below keep existing `crate::util::*` call sites and
|
|
13
|
+
// the napi JS exports (`initThreadPool` / `rayonNumThreads`) working
|
|
14
|
+
// unchanged. Prefer importing from the specific submodules in new code.
|
|
15
|
+
|
|
16
|
+
pub mod batch;
|
|
17
|
+
pub mod batch_core;
|
|
18
|
+
pub mod bytes;
|
|
19
|
+
pub mod packed;
|
|
20
|
+
pub mod text;
|
|
21
|
+
pub mod threadpool;
|
|
22
|
+
pub mod validation;
|
|
23
|
+
|
|
24
|
+
// Re-exported byte primitives so existing callers keep working; the canonical
|
|
25
|
+
// implementations live in `bytes.rs`.
|
|
26
|
+
pub use self::bytes::trim_ascii_whitespace;
|
|
27
|
+
|
|
28
|
+
pub(crate) use self::batch::{run_bitset_batch, run_packed_batch, run_packed_batch_idx};
|
|
29
|
+
pub use self::batch_core::{count_batch, sum_batch_i64, validation_bitset_chunked};
|
|
30
|
+
pub use self::packed::{
|
|
31
|
+
ensure_capacity, run_packed_into, slices_overlap, total_bytes, unpack, write_bitset_batch_into,
|
|
32
|
+
write_bytes, write_bytes_lowercase, write_sum_batch_into, write_u32_batch_into, write_u32_le,
|
|
33
|
+
};
|
|
34
|
+
pub use self::threadpool::{init_thread_pool, rayon_num_threads, should_parallelize};
|