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.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. 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"