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,128 @@
|
|
|
1
|
+
// rust/util/threadpool.rs — Rayon global thread-pool initialization + parallelism.
|
|
2
|
+
//
|
|
3
|
+
// The rayon pool is process-wide and initialized exactly once (OnceLock). This
|
|
4
|
+
// module owns the pool lifecycle and the "should we parallelize?" heuristic;
|
|
5
|
+
// the generic batch helpers in `batch_core.rs` and the feature modules route
|
|
6
|
+
// their parallel work through `should_parallelize`.
|
|
7
|
+
|
|
8
|
+
use napi::{Error, Result};
|
|
9
|
+
use napi_derive::napi;
|
|
10
|
+
use std::sync::OnceLock;
|
|
11
|
+
|
|
12
|
+
static RAYON_INIT: OnceLock<std::result::Result<(), String>> = OnceLock::new();
|
|
13
|
+
|
|
14
|
+
#[cfg(target_os = "linux")]
|
|
15
|
+
static CORE_IDS: OnceLock<Option<Vec<core_affinity::CoreId>>> = OnceLock::new();
|
|
16
|
+
|
|
17
|
+
/// Read the first set env var among `preferred` and its legacy aliases.
|
|
18
|
+
///
|
|
19
|
+
/// Centralizes the `CASTRUM_*` + legacy `RUST_BENCH_*` alias chains (mirrors
|
|
20
|
+
/// `src/shared/env.ts` on the TS side) so the chains can never drift apart.
|
|
21
|
+
fn read_env(preferred: &str, legacy: &[&str]) -> Option<String> {
|
|
22
|
+
std::env::var(preferred)
|
|
23
|
+
.ok()
|
|
24
|
+
.or_else(|| legacy.iter().find_map(|key| std::env::var(key).ok()))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#[napi]
|
|
28
|
+
pub fn init_thread_pool(rayon_threads: Option<u32>) -> Result<()> {
|
|
29
|
+
let stored = RAYON_INIT.get_or_init(|| {
|
|
30
|
+
let default_threads = std::thread::available_parallelism()
|
|
31
|
+
.map(|n| n.get() as u32)
|
|
32
|
+
.unwrap_or(2);
|
|
33
|
+
|
|
34
|
+
// Leave a little headroom for Bun/internal runtime threads.
|
|
35
|
+
let preferred = default_threads.saturating_sub(1).max(1);
|
|
36
|
+
|
|
37
|
+
// Do not hard-cap to 8 by default.
|
|
38
|
+
// If you need a cap, set CASTRUM_MAX_RAYON_THREADS
|
|
39
|
+
// (legacy alias: RUST_BENCH_MAX_RAYON_THREADS).
|
|
40
|
+
let max_threads = read_env(
|
|
41
|
+
"CASTRUM_MAX_RAYON_THREADS",
|
|
42
|
+
&["RUST_BENCH_MAX_RAYON_THREADS"],
|
|
43
|
+
)
|
|
44
|
+
.and_then(|v| v.parse().ok())
|
|
45
|
+
.unwrap_or(default_threads.max(1));
|
|
46
|
+
|
|
47
|
+
let threads = rayon_threads
|
|
48
|
+
.unwrap_or(preferred)
|
|
49
|
+
.clamp(1, max_threads.max(1));
|
|
50
|
+
|
|
51
|
+
let built = rayon::ThreadPoolBuilder::new()
|
|
52
|
+
.num_threads(threads as usize)
|
|
53
|
+
.stack_size(512 * 1024)
|
|
54
|
+
.thread_name(|i| format!("castrum-rayon-{}", i))
|
|
55
|
+
.start_handler(pin_rayon_thread)
|
|
56
|
+
.build_global();
|
|
57
|
+
match built {
|
|
58
|
+
Ok(()) => Ok(()),
|
|
59
|
+
Err(e) => {
|
|
60
|
+
let msg = e.to_string();
|
|
61
|
+
if msg.contains("already been initialized") {
|
|
62
|
+
// Rayon's GLOBAL pool may already exist (e.g. a direct
|
|
63
|
+
// par_iter call auto-created it with defaults before our
|
|
64
|
+
// explicit init, such as an instance batch method). That
|
|
65
|
+
// is NOT an error — the pool is up and usable; our
|
|
66
|
+
// thread-count request is simply moot. Treat as success so
|
|
67
|
+
// the OnceLock is never poisoned by this benign race.
|
|
68
|
+
Ok(())
|
|
69
|
+
} else {
|
|
70
|
+
Err(msg)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
match stored {
|
|
77
|
+
Ok(()) => Ok(()),
|
|
78
|
+
Err(msg) => Err(Error::from_reason(msg.clone())),
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[cfg(target_os = "linux")]
|
|
83
|
+
fn pin_rayon_thread(id: usize) {
|
|
84
|
+
if read_env("CASTRUM_PIN_CORES", &["RUST_BENCH_PIN_CORES"]).is_none() {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let ids = CORE_IDS.get_or_init(core_affinity::get_core_ids);
|
|
89
|
+
if let Some(ids) = ids {
|
|
90
|
+
if ids.len() > 1 {
|
|
91
|
+
let idx = 1 + (id % (ids.len() - 1));
|
|
92
|
+
let _ = core_affinity::set_for_current(ids[idx]);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
#[cfg(not(target_os = "linux"))]
|
|
98
|
+
fn pin_rayon_thread(_id: usize) {}
|
|
99
|
+
|
|
100
|
+
#[napi]
|
|
101
|
+
pub fn rayon_num_threads() -> u32 {
|
|
102
|
+
rayon::current_num_threads() as u32
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Heuristic: parallelize a batch of `items` (totalling `bytes`) when the
|
|
106
|
+
/// workload is large enough to beat the rayon scheduling overhead.
|
|
107
|
+
#[inline(always)]
|
|
108
|
+
pub fn should_parallelize(items: usize, bytes: usize) -> bool {
|
|
109
|
+
let threads = rayon::current_num_threads().max(1);
|
|
110
|
+
items >= threads.saturating_mul(2048) || bytes >= threads.saturating_mul(1024 * 1024)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
#[cfg(test)]
|
|
114
|
+
mod tests {
|
|
115
|
+
#[test]
|
|
116
|
+
fn init_thread_pool_is_idempotent() {
|
|
117
|
+
// The rayon global pool is process-wide and first-call-wins. A second
|
|
118
|
+
// init_thread_pool (after a prior init OR after a direct par_iter
|
|
119
|
+
// auto-initialized rayon) must return Ok — never a poisoned error.
|
|
120
|
+
let first = super::init_thread_pool(Some(2));
|
|
121
|
+
let second = super::init_thread_pool(Some(2));
|
|
122
|
+
assert!(first.is_ok(), "first init must succeed (got {first:?})");
|
|
123
|
+
assert!(
|
|
124
|
+
second.is_ok(),
|
|
125
|
+
"second init must be a no-op, not an error (got {second:?})"
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
// rust/util/validation.rs — email / UUID / IPv4 / IPv6 validators.
|
|
2
|
+
//
|
|
3
|
+
// Each validator has a `*_bytes` zero-DOM core (the C-ABI + packed-batch
|
|
4
|
+
// paths) and a napi entry point. IPv4 dot-splitting uses `memchr`; the
|
|
5
|
+
// `fast-email` feature gates a regex-free email fast path.
|
|
6
|
+
|
|
7
|
+
#[cfg(not(feature = "fast-email"))]
|
|
8
|
+
use email_address::EmailAddress;
|
|
9
|
+
|
|
10
|
+
use napi::bindgen_prelude::*;
|
|
11
|
+
use napi_derive::napi;
|
|
12
|
+
use std::net::Ipv6Addr;
|
|
13
|
+
|
|
14
|
+
#[cfg(feature = "fast-email")]
|
|
15
|
+
#[inline]
|
|
16
|
+
fn email_is_valid(s: &str) -> bool {
|
|
17
|
+
fast_chemail::is_valid_email(s)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[cfg(not(feature = "fast-email"))]
|
|
21
|
+
#[inline]
|
|
22
|
+
fn email_is_valid(s: &str) -> bool {
|
|
23
|
+
EmailAddress::is_valid(s)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
#[inline]
|
|
27
|
+
pub fn validate_email_bytes(input: &[u8]) -> bool {
|
|
28
|
+
std::str::from_utf8(input)
|
|
29
|
+
.map(email_is_valid)
|
|
30
|
+
.unwrap_or(false)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
#[inline]
|
|
34
|
+
pub fn validate_uuid_bytes(input: &[u8]) -> bool {
|
|
35
|
+
let Ok(b) = <&[u8; 36]>::try_from(input) else {
|
|
36
|
+
return false;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Unroll the dash positions check.
|
|
40
|
+
if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if b[14] != b'4' {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
if !matches!(b[19], b'8' | b'9' | b'a' | b'b' | b'A' | b'B') {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Validate hex digits at all other positions.
|
|
51
|
+
// Use a lookup table for speed.
|
|
52
|
+
const HEX: [bool; 256] = {
|
|
53
|
+
let mut t = [false; 256];
|
|
54
|
+
let mut i = 0;
|
|
55
|
+
while i < 256 {
|
|
56
|
+
t[i] = (i as u8).is_ascii_hexdigit();
|
|
57
|
+
i += 1;
|
|
58
|
+
}
|
|
59
|
+
t
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
let mut i = 0usize;
|
|
63
|
+
while i < 36 {
|
|
64
|
+
if i == 8 || i == 13 || i == 18 || i == 23 {
|
|
65
|
+
i += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if i == 14 || i == 19 {
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if !HEX[b[i] as usize] {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
i += 1;
|
|
76
|
+
}
|
|
77
|
+
true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
#[inline]
|
|
81
|
+
fn is_valid_ipv4(b: &[u8]) -> bool {
|
|
82
|
+
if b.is_empty() || b.len() > 15 {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let mut parts = 0u8;
|
|
87
|
+
let mut start = 0usize;
|
|
88
|
+
|
|
89
|
+
// memchr-driven dot scan (the optimization the comment always promised;
|
|
90
|
+
// the previous code walked the bytes with enumerate/filter_map).
|
|
91
|
+
while let Some(rel) = memchr::memchr(b'.', &b[start..]) {
|
|
92
|
+
let dot_idx = start + rel;
|
|
93
|
+
let part = &b[start..dot_idx];
|
|
94
|
+
if !validate_ipv4_part(part) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
parts += 1;
|
|
98
|
+
// 4 dots means 5 parts → always invalid; reject as soon as the 4th
|
|
99
|
+
// dot is consumed (identical boolean result to the old count-then-
|
|
100
|
+
// compare, with an earlier exit).
|
|
101
|
+
if parts > 3 {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
start = dot_idx + 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Last part.
|
|
108
|
+
if !validate_ipv4_part(&b[start..]) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
parts += 1;
|
|
112
|
+
|
|
113
|
+
parts == 4
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
#[inline(always)]
|
|
117
|
+
fn validate_ipv4_part(part: &[u8]) -> bool {
|
|
118
|
+
if part.is_empty() || part.len() > 3 {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
if part.len() > 1 && part[0] == b'0' {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let mut n = 0u16;
|
|
126
|
+
for &c in part {
|
|
127
|
+
if !c.is_ascii_digit() {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
n = n * 10 + u16::from(c - b'0');
|
|
131
|
+
}
|
|
132
|
+
n <= 255
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[inline]
|
|
136
|
+
pub fn validate_ipv4_bytes(input: &[u8]) -> bool {
|
|
137
|
+
is_valid_ipv4(input)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#[inline]
|
|
141
|
+
pub fn validate_ipv6_bytes(input: &[u8]) -> bool {
|
|
142
|
+
simdutf8::basic::from_utf8(input)
|
|
143
|
+
.ok()
|
|
144
|
+
.and_then(|s| s.parse::<Ipv6Addr>().ok())
|
|
145
|
+
.is_some()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#[napi]
|
|
149
|
+
pub fn validate_email(input: Uint8Array) -> bool {
|
|
150
|
+
validate_email_bytes(input.as_ref())
|
|
151
|
+
}
|
|
152
|
+
#[napi]
|
|
153
|
+
pub fn validate_uuid(input: Uint8Array) -> bool {
|
|
154
|
+
validate_uuid_bytes(input.as_ref())
|
|
155
|
+
}
|
|
156
|
+
#[napi]
|
|
157
|
+
pub fn validate_ipv4(input: Uint8Array) -> bool {
|
|
158
|
+
validate_ipv4_bytes(input.as_ref())
|
|
159
|
+
}
|
|
160
|
+
#[napi]
|
|
161
|
+
pub fn validate_ipv6(input: Uint8Array) -> bool {
|
|
162
|
+
validate_ipv6_bytes(input.as_ref())
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Batch fixed-width hex validation ────────────────────────────────────────
|
|
166
|
+
// Generic "is this a `width`-char hex string?" over NEWLINE-separated lines —
|
|
167
|
+
// the batch form of the ObjectId/hex-token checks every backend scatters as
|
|
168
|
+
// per-item regexes (`/^[0-9a-f]{24}$/` × N). One crossing validates a whole
|
|
169
|
+
// page of ids and writes one verdict byte (1/0) per line.
|
|
170
|
+
|
|
171
|
+
/// Max accepted hex width (an absurd width is caller error, not input error).
|
|
172
|
+
pub const HEX_BATCH_MAX_WIDTH: usize = 4096;
|
|
173
|
+
|
|
174
|
+
/// 256-entry hex-digit lookup table — one indexed load per byte instead of
|
|
175
|
+
/// three range compares in the batch hot loop.
|
|
176
|
+
const HEX_LUT: [u8; 256] = {
|
|
177
|
+
let mut t = [0u8; 256];
|
|
178
|
+
let mut i = 0usize;
|
|
179
|
+
while i < 256 {
|
|
180
|
+
let c = i as u8;
|
|
181
|
+
t[i] = (c.is_ascii_digit() || matches!(c, b'a'..=b'f' | b'A'..=b'F')) as u8;
|
|
182
|
+
i += 1;
|
|
183
|
+
}
|
|
184
|
+
t
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/// Validate newline-separated strings as fixed-width hex, appending one
|
|
188
|
+
/// verdict byte (1/0) per line to `out`. A trailing `\n` is allowed; `\r` is
|
|
189
|
+
/// tolerated before `\n` (CRLF callers). Empty input → empty output.
|
|
190
|
+
/// Errors only on a nonsensical `width` (0 or > [`HEX_BATCH_MAX_WIDTH`]).
|
|
191
|
+
pub fn hex_batch_valid_into(
|
|
192
|
+
input: &[u8],
|
|
193
|
+
width: usize,
|
|
194
|
+
out: &mut Vec<u8>,
|
|
195
|
+
) -> std::result::Result<(), String> {
|
|
196
|
+
if width == 0 || width > HEX_BATCH_MAX_WIDTH {
|
|
197
|
+
return Err(format!("width must be 1..={HEX_BATCH_MAX_WIDTH}"));
|
|
198
|
+
}
|
|
199
|
+
// Fast path first: uniform-stride batches (`<width hex>\n` repeated — the
|
|
200
|
+
// 99% shape: same-length ObjectIds joined with '\n') validate without any
|
|
201
|
+
// per-line slicing, at memcpy-ish speed. Any layout deviation (short/
|
|
202
|
+
// long lines, CRLF, embedded separators) falls back to the memchr path,
|
|
203
|
+
// so semantics are byte-identical.
|
|
204
|
+
if try_uniform_stride(input, width, out) {
|
|
205
|
+
return Ok(());
|
|
206
|
+
}
|
|
207
|
+
// memchr-driven line splitting: the SIMD newline scan dominates the cost,
|
|
208
|
+
// and each line gets an early-exit exact-width + hex-digit check.
|
|
209
|
+
let mut start = 0usize;
|
|
210
|
+
while let Some(rel) = memchr::memchr(b'\n', &input[start..]) {
|
|
211
|
+
let end = start + rel;
|
|
212
|
+
push_line_verdict(&input[start..end], width, out);
|
|
213
|
+
start = end + 1;
|
|
214
|
+
}
|
|
215
|
+
// Trailing segment without a final newline (skip when empty — a trailing
|
|
216
|
+
// `\n` does not open an extra line).
|
|
217
|
+
if start < input.len() {
|
|
218
|
+
push_line_verdict(&input[start..], width, out);
|
|
219
|
+
}
|
|
220
|
+
Ok(())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/// Uniform-stride validator: `<line>\n` repeated with EVERY line exactly
|
|
224
|
+
/// `width` bytes. Appends one verdict per line and returns `true`; returns
|
|
225
|
+
/// `false` (touching nothing) when the layout deviates.
|
|
226
|
+
#[inline]
|
|
227
|
+
fn try_uniform_stride(input: &[u8], width: usize, out: &mut Vec<u8>) -> bool {
|
|
228
|
+
if input.is_empty() {
|
|
229
|
+
return true; // zero lines, zero verdicts
|
|
230
|
+
}
|
|
231
|
+
let stride = width.saturating_add(1);
|
|
232
|
+
let last = input[input.len() - 1]; // non-empty checked above
|
|
233
|
+
let has_trailing = last == b'\n';
|
|
234
|
+
let n = if has_trailing {
|
|
235
|
+
if !input.len().is_multiple_of(stride) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
input.len() / stride
|
|
239
|
+
} else if input.len() % stride == width {
|
|
240
|
+
input.len() / stride + 1
|
|
241
|
+
} else {
|
|
242
|
+
return false;
|
|
243
|
+
};
|
|
244
|
+
out.reserve(n);
|
|
245
|
+
let mut pos = 0usize;
|
|
246
|
+
for line_idx in 0..n {
|
|
247
|
+
let end = pos + width;
|
|
248
|
+
let needs_sep = !(line_idx + 1 == n && !has_trailing);
|
|
249
|
+
if needs_sep && input[end] != b'\n' {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
let mut all_hex = true;
|
|
253
|
+
for &c in &input[pos..end] {
|
|
254
|
+
if HEX_LUT[c as usize] == 0 {
|
|
255
|
+
all_hex = false;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
out.push(u8::from(all_hex));
|
|
260
|
+
pos = end + 1;
|
|
261
|
+
}
|
|
262
|
+
true
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/// Validate one line (no trailing newline): tolerate ONE trailing `\r`, then
|
|
266
|
+
/// require exact width + all hex digits.
|
|
267
|
+
#[inline]
|
|
268
|
+
fn push_line_verdict(line: &[u8], width: usize, out: &mut Vec<u8>) {
|
|
269
|
+
let line = if line.last() == Some(&b'\r') {
|
|
270
|
+
&line[..line.len() - 1]
|
|
271
|
+
} else {
|
|
272
|
+
line
|
|
273
|
+
};
|
|
274
|
+
let ok = line.len() == width && line.iter().all(|&c| HEX_LUT[c as usize] != 0);
|
|
275
|
+
out.push(u8::from(ok));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/// Number of verdict bytes `hex_batch_valid_into` will append for `input`
|
|
279
|
+
/// (the needed-size convention's pre-computed size — no second validation
|
|
280
|
+
/// pass, just a newline count).
|
|
281
|
+
#[inline]
|
|
282
|
+
pub fn hex_batch_count(input: &[u8]) -> usize {
|
|
283
|
+
let lines = memchr::memchr_iter(b'\n', input).count();
|
|
284
|
+
// A trailing newline does not open an extra empty line.
|
|
285
|
+
if input.last() == Some(&b'\n') {
|
|
286
|
+
lines
|
|
287
|
+
} else {
|
|
288
|
+
lines + usize::from(!input.is_empty())
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/// Batch fixed-width hex validation → one verdict byte (1/0) per
|
|
293
|
+
/// newline-separated line. `width` is the exact expected string length
|
|
294
|
+
/// (e.g. 24 for Mongo ObjectIds). (JS name: `hexValidateBatch`.)
|
|
295
|
+
#[napi]
|
|
296
|
+
pub fn hex_validate_batch(input: Uint8Array, width: u32) -> Result<Uint8Array> {
|
|
297
|
+
let mut out = Vec::with_capacity(hex_batch_count(input.as_ref()));
|
|
298
|
+
hex_batch_valid_into(input.as_ref(), width as usize, &mut out)
|
|
299
|
+
.map_err(|e| napi::Error::new(napi::Status::InvalidArg, e))?;
|
|
300
|
+
Ok(out.into())
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
#[cfg(test)]
|
|
304
|
+
mod tests {
|
|
305
|
+
use super::*;
|
|
306
|
+
|
|
307
|
+
#[test]
|
|
308
|
+
fn validate_email_cases() {
|
|
309
|
+
assert!(validate_email_bytes(b"a@b.com"));
|
|
310
|
+
assert!(!validate_email_bytes(b"not-an-email"));
|
|
311
|
+
assert!(!validate_email_bytes(b""));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
#[test]
|
|
315
|
+
fn validate_uuid_v4_cases() {
|
|
316
|
+
assert!(validate_uuid_bytes(b"550e8400-e29b-41d4-a716-446655440000"));
|
|
317
|
+
assert!(!validate_uuid_bytes(b"550e8400e29b41d4a716446655440000")); // no dashes
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[test]
|
|
321
|
+
fn validate_ipv4_cases() {
|
|
322
|
+
assert!(validate_ipv4_bytes(b"192.168.0.1"));
|
|
323
|
+
assert!(validate_ipv4_bytes(b"1.2.3.4"));
|
|
324
|
+
assert!(!validate_ipv4_bytes(b"999.1.1.1"));
|
|
325
|
+
assert!(!validate_ipv4_bytes(b"1.2.3"));
|
|
326
|
+
assert!(!validate_ipv4_bytes(b""));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
#[test]
|
|
330
|
+
fn validate_ipv6_cases() {
|
|
331
|
+
assert!(validate_ipv6_bytes(b"::1"));
|
|
332
|
+
assert!(validate_ipv6_bytes(b"2001:db8::1"));
|
|
333
|
+
assert!(!validate_ipv6_bytes(b"not-an-ip"));
|
|
334
|
+
assert!(!validate_ipv6_bytes(b""));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#[test]
|
|
338
|
+
fn hex_batch_basic_and_object_ids() {
|
|
339
|
+
let mut out = Vec::new();
|
|
340
|
+
hex_batch_valid_into(
|
|
341
|
+
b"507f1f77bcf86cd799439011\n507F1F77BCF86CD799439012\nnothex\nshort\n507f1f77bcf86cd79943901z",
|
|
342
|
+
24,
|
|
343
|
+
&mut out,
|
|
344
|
+
)
|
|
345
|
+
.expect("ok");
|
|
346
|
+
assert_eq!(out, vec![1, 1, 0, 0, 0]);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#[test]
|
|
350
|
+
fn hex_batch_trailing_newline_and_crlf() {
|
|
351
|
+
let mut out = Vec::new();
|
|
352
|
+
hex_batch_valid_into(b"deadbeef\r\nDEADBEEF\n", 8, &mut out).expect("ok");
|
|
353
|
+
assert_eq!(out, vec![1, 1]);
|
|
354
|
+
// count matches the written length (needed-size convention pre-size)
|
|
355
|
+
assert_eq!(hex_batch_count(b"deadbeef\r\nDEADBEEF\n"), 2);
|
|
356
|
+
assert_eq!(hex_batch_count(b""), 0);
|
|
357
|
+
assert_eq!(hex_batch_count(b"a\nb"), 2);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
#[test]
|
|
361
|
+
fn hex_batch_width_validation() {
|
|
362
|
+
let mut out = Vec::new();
|
|
363
|
+
assert!(hex_batch_valid_into(b"ab", 0, &mut out).is_err());
|
|
364
|
+
assert!(hex_batch_valid_into(b"ab", HEX_BATCH_MAX_WIDTH + 1, &mut out).is_err());
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/// Reference implementation (memchr split + per-line check) used to pin
|
|
368
|
+
/// the uniform-stride fast path.
|
|
369
|
+
fn hex_reference(input: &[u8], width: usize) -> Vec<u8> {
|
|
370
|
+
let mut out = Vec::new();
|
|
371
|
+
let mut start = 0usize;
|
|
372
|
+
while let Some(rel) = memchr::memchr(b'\n', &input[start..]) {
|
|
373
|
+
let end = start + rel;
|
|
374
|
+
push_line_verdict(&input[start..end], width, &mut out);
|
|
375
|
+
start = end + 1;
|
|
376
|
+
}
|
|
377
|
+
if start < input.len() {
|
|
378
|
+
push_line_verdict(&input[start..], width, &mut out);
|
|
379
|
+
}
|
|
380
|
+
out
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#[test]
|
|
384
|
+
fn hex_batch_uniform_fast_path_matches_fallback() {
|
|
385
|
+
let cases: Vec<(String, usize)> = vec![
|
|
386
|
+
(
|
|
387
|
+
"507f1f77bcf86cd799439011\n507F1F77BCF86CD799439012\n".into(),
|
|
388
|
+
24,
|
|
389
|
+
),
|
|
390
|
+
("507f1f77bcf86cd799439011\nzz\n".into(), 24),
|
|
391
|
+
("ab\ncd\n".into(), 2),
|
|
392
|
+
("ab\nc\n".into(), 2), // short line → fallback
|
|
393
|
+
("ab\r\ncd\r\n".into(), 2), // CRLF → fallback
|
|
394
|
+
("abc\nab\n".into(), 2), // long line breaks stride → fallback
|
|
395
|
+
("ab".into(), 2), // no trailing newline
|
|
396
|
+
("abcd".into(), 2), // two lines, no trailing newline
|
|
397
|
+
("ab\ncd\nef".into(), 2), // trailing partial line
|
|
398
|
+
("\n".into(), 2), // single empty line
|
|
399
|
+
("\n\n".into(), 2), // empty lines
|
|
400
|
+
("".into(), 4), // empty input
|
|
401
|
+
("a\nb\nc".into(), 1),
|
|
402
|
+
];
|
|
403
|
+
for (input, width) in cases {
|
|
404
|
+
let bytes = input.as_bytes();
|
|
405
|
+
let mut fast_out = Vec::new();
|
|
406
|
+
let took_fast = try_uniform_stride(bytes, width, &mut fast_out);
|
|
407
|
+
let mut out = Vec::new();
|
|
408
|
+
hex_batch_valid_into(bytes, width, &mut out).expect("valid width");
|
|
409
|
+
let expected = hex_reference(bytes, width);
|
|
410
|
+
assert_eq!(out, expected, "public path diverged for {input:?} @{width}");
|
|
411
|
+
if took_fast {
|
|
412
|
+
assert_eq!(
|
|
413
|
+
fast_out, expected,
|
|
414
|
+
"fast path diverged for {input:?} @ width {width}"
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Pin the Rust toolchain for reproducible builds (CI + release).
|
|
2
|
+
#
|
|
3
|
+
# Cargo/rustup read this file and use exactly this version, so a release build
|
|
4
|
+
# on any machine produces the same compiler behavior. Bump deliberately only
|
|
5
|
+
# after a newer stable is verified against the full test suite
|
|
6
|
+
# (`cargo test`, `cargo clippy -- -D warnings`, `bun run check:proven`).
|
|
7
|
+
#
|
|
8
|
+
# 1.98.0 (2026-08-18): full `cargo test` + `cargo clippy --all-targets -D
|
|
9
|
+
# warnings` green; clippy's new chunks_exact_to_as_chunks lint adopted in the
|
|
10
|
+
# two hot-path is_ascii() helpers (typed [u8;8] chunks, no per-chunk try_from).
|
|
11
|
+
#
|
|
12
|
+
# Note: the local Arch system toolchain (not rustup-managed) ignores this file;
|
|
13
|
+
# it primarily pins the CI `dtolnay/rust-toolchain` and rustup-managed builds.
|
|
14
|
+
[toolchain]
|
|
15
|
+
channel = "1.98.0"
|
|
16
|
+
components = ["rustfmt", "clippy"]
|
|
17
|
+
profile = "minimal"
|