castrum 0.9.9 → 0.9.10

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 CHANGED
@@ -233,7 +233,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
233
233
 
234
234
  [[package]]
235
235
  name = "castrum"
236
- version = "0.9.9"
236
+ version = "0.9.10"
237
237
  dependencies = [
238
238
  "argon2",
239
239
  "aws-lc-rs",
@@ -245,6 +245,7 @@ dependencies = [
245
245
  "email_address",
246
246
  "fancy-regex",
247
247
  "fast_chemail",
248
+ "faster-hex",
248
249
  "flate2",
249
250
  "fraction",
250
251
  "getrandom 0.4.3",
@@ -477,6 +478,15 @@ dependencies = [
477
478
  "ascii_utils",
478
479
  ]
479
480
 
481
+ [[package]]
482
+ name = "faster-hex"
483
+ version = "0.9.0"
484
+ source = "registry+https://github.com/rust-lang/crates.io-index"
485
+ checksum = "a2a2b11eda1d40935b26cf18f6833c526845ae8c41e58d09af6adeb6f0269183"
486
+ dependencies = [
487
+ "serde",
488
+ ]
489
+
480
490
  [[package]]
481
491
  name = "fastrand"
482
492
  version = "2.5.0"
package/Cargo.toml CHANGED
@@ -1,7 +1,7 @@
1
1
  [package]
2
2
  name = "castrum"
3
3
  # Keep in sync with package.json "version" (single source of truth: the npm package).
4
- version = "0.9.9"
4
+ version = "0.9.10"
5
5
  edition = "2021"
6
6
  # Keep in sync with package.json "license" (cargo-deny rejects a crate with no
7
7
  # license expression).
@@ -45,8 +45,11 @@ sonic-rs = { version = "0.5", features = ["sort_keys"] }
45
45
  # validators (email etc.) are built-in and NOT feature-gated in 0.48.
46
46
  jsonschema = { version = "0.49", default-features = false, features = ["resolve-file"] }
47
47
  aws-lc-rs = "1"
48
- # NOTE: base64 0.23 enables SIMD (SSE/AVX) by default via the `simd-unsafe`
49
- # feature (part of `default`); no explicit feature is needed.
48
+ # NOTE: base64 0.23 compiles its SIMD `engine::simd::Simd` (AVX2/NEON, Muła
49
+ # kernels) by default via the `simd-unsafe` feature (part of `default`). Our
50
+ # `rust/crypto/base64.rs` selects that engine for the hot cores on x86_64 /
51
+ # aarch64, with the scalar `GeneralPurpose` fallback elsewhere; the wire
52
+ # contract (PAD/NO_PAD configs, byte-identical output) is unchanged.
50
53
  base64 = "0.23"
51
54
  crc32fast = "1"
52
55
  httparse = "1"
@@ -72,7 +75,18 @@ mimalloc = { version = "0.1", features = ["local_dynamic_tls"] }
72
75
  core_affinity = "0.8"
73
76
 
74
77
  parking_lot = "0.12"
78
+ # xxhash-rust: seeded streaming Xxh3 (ingress ip_trust rate_key, tiny 1-17
79
+ # byte IPs) AND the public one-shot xxh3_64. Measured: the scalar core
80
+ # sustains a FLAT ~30GB/s from 64B to 1MB (no size pathology); the ~2x gap to
81
+ # Bun.hash.xxHash3 at >= 1KB is Bun's AVX2 width, not a scaling issue, and
82
+ # ignex already delegates the public hash to Bun under Bun — see
83
+ # docs/bun-builtins-decision-matrix.md.
75
84
  xxhash-rust = { version = "0.8", features = ["xxh3"] }
85
+ # faster-hex: SIMD (runtime-dispatched SSE -> AVX2) lowercase hex encode/decode
86
+ # with byte-exact semantics parity vs our HEX_VAL_LUT (case-insensitive decode,
87
+ # reject [^0-9a-fA-F], odd-length error) — verified exhaustively (65,536 pairs).
88
+ # Replaces the per-byte table loops that lost ~3-6x to Buffer.toString("hex").
89
+ faster-hex = "0.9"
76
90
  ipnet = "2"
77
91
  # 0.18.2+ carries the RUSTSEC-2026-0253 fix (0.16.x is affected); keep this
78
92
  # requirement at or above the fixed release so `cargo deny` / OSV stay green.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -31,7 +31,7 @@
31
31
  |---|---|---|---|---|
32
32
  | `rust.fnv1a64` | `Bun.hash` (wyhash) | 42 B | Bun (parity) | 1.06× |
33
33
  | `rust.crc32` | `Bun.hash.crc32` | 42 B | **Bun** | 2.8–8.4× |
34
- | `rust.xxh3` (new) | `Bun.hash.xxHash3` | 42 B | **Bun** | 4.15× |
34
+ | `rust.xxh3` (new) | `Bun.hash.xxHash3` | 42 B–1 MB | **Bun** (≥1 KB) | 0.97× (≤64 B); ~2.0× (≥1 KB) |
35
35
  | `rust.hmacSha256` | `Bun.CryptoHasher("sha256", key)` | 32 B | Bun (mild) | 1.1–1.4× |
36
36
  | `rust.passwordHash` (argon2id) | `Bun.password.hashSync` (argon2id) | 12 B | **rust** | 0.55× (rust 1.83×) |
37
37
  | `rust.passwordVerify` | `Bun.password.verifySync` | 12 B | **rust** | 0.53× (rust 1.88×) |
@@ -62,6 +62,14 @@
62
62
  These rust ops lose to Bun's native implementation on the SAME workload — the
63
63
  FFI crossing cannot beat Bun's in-process C++:
64
64
 
65
+ > Re-swept 2026-09-19 (64 B → 1 MB, interleaved medians + per-call input
66
+ > rotation): the hex and url rows in this table no longer lose — rust hex
67
+ > encode/decode is now 4.7–18.9× / 1.4–6.7× over Buffer, the url codec
68
+ > ~2–3× over `encodeURIComponent`, and base64 decode wins 1.4–6.0×. Only the
69
+ > rows still marked "Prefer Bun" below remain genuinely faster in Bun. See
70
+ > the rows themselves and `src/runtime/builtins.ts` for what the adapter
71
+ > currently delegates.
72
+
65
73
  This table mirrors `BUILTIN_OPS` in `src/runtime/builtins.ts` (the 11 ops that
66
74
  delegate to Bun built-ins under Bun; `src/selection.ts` derives from it). `fnv1a64` is parity/either but is NOT delegated
67
75
  (`Bun.hash` is wyhash — a different algorithm — and rust `fnv1a64` stays the
@@ -70,13 +78,13 @@ benchmarked impl).
70
78
  | Op | Recommendation |
71
79
  |---|---|
72
80
  | `crc32` | Prefer `Bun.hash.crc32`; keep rust as fallback (pure-TS path already exists). |
73
- | `xxh3` | Prefer `Bun.hash.xxHash3` (~4×); keep the rust export as the Node/non-Bun fast path. The runtime adapter delegates the public `rust.xxh3` to Bun under Bun. |
81
+ | `xxh3` | Prefer `Bun.hash.xxHash3` (~2× at ≥1 KB via AVX2; parity ≤64 B). The rust core is FLAT ~30 GB/s at every size (re-swept 2026-09-19 — the gap is Bun's AVX2 width, not a size pathology); keep it as the Node/non-Bun fast path. The adapter delegates the public `rust.xxh3` to Bun under Bun. |
74
82
  | `gzipCompress` | Prefer `Bun.gzipSync` (~2×). **`gzipDecompress` is deliberately NOT delegated** — `Bun.gunzipSync` has no decompression-bomb cap; the rust surface keeps its native 64 MiB-capped path under Bun. |
75
83
  | `randomToken` | Prefer `Bun.randomUUIDv7()` (or `crypto.getRandomValues`) for token-sized output; keep rust for byte-precise control. |
76
84
  | `hmacSha256` | Prefer `Bun.CryptoHasher("sha256", key)` (1.17× — mild; keep rust batch path, which wins on larger inputs). |
77
- | `urlEncode` / `urlDecode` | Prefer `encodeURIComponent` / `decodeURIComponent` (JSC string builtins — ~11.5×, zero alloc). |
78
- | `base64Encode` / `base64UrlEncode` | Prefer `Buffer.toString('base64'|'base64url')`. |
79
- | `hexEncode` | Prefer `Buffer.toString('hex')`. |
85
+ | `urlEncode` / `urlDecode` | Fan-out. Re-swept 2026-09-19 on escape-heavy input the rust LUT+run-copy codec is ~2.8–3.2× (encode) / ~2× (decode) FASTER than `encodeURIComponent` / `decodeURIComponent` — the earlier ~11.5× reading was input/profile-dependent. The current adapter still delegates via `BUILTIN_OPS`. |
86
+ | `base64Encode` / `base64UrlEncode` | rust wins ≤1 KB (encode 1.8–2.3×); parity ≥16 KiB (0.88–1.07× — both AVX2 and memory-bound). Base64 **decode** is the strong rust play (1.4–6.0×). Fine to prefer rust for both; the adapter currently delegates encode to Buffer per `BUILTIN_OPS`. |
87
+ | `hexEncode` / `hexDecode` | Prefer rust: encode 4.7–18.9×, decode 1.4–6.7× over Buffer (faster-hex SIMD + single-pass AVX2 decode kernel; re-swept 2026-09-19 — previously Buffer won). The adapter still delegates to Buffer per `BUILTIN_OPS`. |
80
88
  | `httpDate` | Prefer `Date.toUTCString()`. |
81
89
 
82
90
  **Delegation rule of thumb**: rust stays the default only where it beats its
@@ -96,7 +104,7 @@ genuinely wins or Bun has no equivalent.
96
104
  ### → Implemented primitives (gaps closed)
97
105
  | Primitive | Outcome |
98
106
  |---|---|
99
- | `rust.xxh3` (XXH3-64) | Exposed; measured `Bun.hash.xxHash3` 4.15× faster → the runtime adapter delegates the public `rust.xxh3` to Bun under Bun (Node keeps the addon). |
107
+ | `rust.xxh3` (XXH3-64) | Exposed; re-swept 2026-09-19: parity ≤64 B, Bun ~2× at ≥1 KB (its AVX2 width; rust is flat 30 GB/s) → the adapter still delegates under Bun (Node keeps the addon path). |
100
108
  | `rust.passwordHashBcrypt` / `rust.passwordVerifyBcrypt` | bcrypt `$2b$` PHC; hash parity, verify rust 1.49×. |
101
109
  | `rust.pbkdf2Sha256` | PBKDF2-HMAC-SHA256; parity with `node:crypto`, the only sync option in Bun. |
102
110
  | UUIDv7 (`uuidv7()`) | `Bun.randomUUIDv7` already wins — delegated to Bun (`crypto.randomUUID` on Node), not built in Rust. |
@@ -108,7 +116,9 @@ random tokens, and gzip, Bun 1.4's built-ins are already faster than any
108
116
  FFI-crossing Rust op — the selection layer should prefer them. Rust retains a clear,
109
117
  defensible moat exactly where it matters: **argon2id password hashing
110
118
  (≈1.9× vs Bun)**, the **zero-DOM / zero-copy parsers and JSON paths** (no Bun
111
- sync equivalent), and **brotli** (no Bun sync API).
119
+ sync equivalent), **brotli** (no Bun sync API), and — since the 2026-09-19
120
+ re-sweep — the **hex and URL byte codecs and base64 decode** (1.4–18.9× over
121
+ the corresponding Buffer / URI built-ins at every size).
112
122
 
113
123
  ## Re-run
114
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "castrum",
3
- "version": "0.9.9",
3
+ "version": "0.9.10",
4
4
  "private": false,
5
5
  "description": "Enterprise-grade Bun + Rust runtime: Rust-accelerated FFI primitives, a production-grade HTTP ingress pipeline, validation, hashing, and JSON processing with benchmark-grade performance.",
6
6
  "type": "module",
@@ -1,25 +1,66 @@
1
1
  // rust/crypto/base64.rs — base64 (standard/url-safe) + hex encode/decode.
2
2
  //
3
- // Uses the `base64` crate engines (Cargo dep already present). The
4
- // `Base64Codec` higher-order instance precompiles the alphabet/decoding
5
- // configuration once in its constructor and reuses it across calls.
3
+ // Uses the `base64` crate engines (Cargo dep already present). On the shipped
4
+ // targets (x86_64 / aarch64) the hot cores dispatch to the crate's
5
+ // runtime-detected SIMD engine (`engine::simd::Simd` — AVX2 / NEON kernels):
6
+ // payloads ≥ 64 B (decode) / ≥ 128 B (encode) go through the wide kernels,
7
+ // anything smaller falls back to the scalar engine internally, so both length
8
+ // regimes stay efficient and output stays byte-identical to the scalar path.
9
+ // Other targets keep the scalar `GeneralPurpose` engine (not a shipped
10
+ // castrum target — local dev / test builds only). The `Base64Codec`
11
+ // higher-order instance precompiles the alphabet/decoding configuration once
12
+ // in its constructor and reuses it across calls.
6
13
 
7
14
  use napi::bindgen_prelude::*;
8
15
  use napi_derive::napi;
9
16
 
10
- use crate::util::bytes::{hex_val, HEX_LOWER};
11
17
  use base64::Engine as _;
12
18
 
13
- fn engine(url_safe: bool, padding: bool) -> base64::engine::general_purpose::GeneralPurpose {
19
+ /// Engine selection for a base64 call.
20
+ ///
21
+ /// SIMD branch (x86_64 / aarch64 — every shipped castrum target): the
22
+ /// `engine::simd::Simd` engine detects AVX2 / NEON once at construction and
23
+ /// falls back to the scalar `GeneralPurpose` when the CPU (or a payload below
24
+ /// the 64 B decode / 128 B encode thresholds) doesn't benefit, so small
25
+ /// payloads keep the same fixed costs while large ones get wide kernels. The
26
+ /// `PAD` / `NO_PAD` `GeneralPurposeConfig` consts are the exact configs the
27
+ /// preconfigured scalar engines use, so the wire contract is unchanged.
28
+ #[cfg(any(target_arch = "x86_64", target_arch = "aarch64"))]
29
+ mod engine_sel {
30
+ use base64::engine::general_purpose::{NO_PAD, PAD};
31
+ use base64::engine::simd::Simd;
32
+
33
+ pub(super) type Engine = Simd;
34
+
35
+ pub(super) fn engine(url_safe: bool, padding: bool) -> Simd {
36
+ match (url_safe, padding) {
37
+ (false, true) => Simd::standard(PAD),
38
+ (false, false) => Simd::standard(NO_PAD),
39
+ (true, true) => Simd::url_safe(PAD),
40
+ (true, false) => Simd::url_safe(NO_PAD),
41
+ }
42
+ }
43
+ }
44
+
45
+ /// Scalar fallback (non-x86_64 / non-aarch64 targets only).
46
+ #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
47
+ mod engine_sel {
14
48
  use base64::engine::general_purpose::*;
15
- match (url_safe, padding) {
16
- (false, true) => STANDARD,
17
- (false, false) => STANDARD_NO_PAD,
18
- (true, true) => URL_SAFE,
19
- (true, false) => URL_SAFE_NO_PAD,
49
+
50
+ pub(super) type Engine = GeneralPurpose;
51
+
52
+ pub(super) fn engine(url_safe: bool, padding: bool) -> GeneralPurpose {
53
+ match (url_safe, padding) {
54
+ (false, true) => STANDARD,
55
+ (false, false) => STANDARD_NO_PAD,
56
+ (true, true) => URL_SAFE,
57
+ (true, false) => URL_SAFE_NO_PAD,
58
+ }
20
59
  }
21
60
  }
22
61
 
62
+ use engine_sel::engine;
63
+
23
64
  #[napi]
24
65
  pub fn base64_encode(input: Uint8Array, url_safe: Option<bool>, padding: Option<bool>) -> Buffer {
25
66
  let out = engine(url_safe.unwrap_or(false), padding.unwrap_or(true)).encode(input.as_ref());
@@ -40,16 +81,12 @@ pub fn base64_decode(
40
81
 
41
82
  #[napi]
42
83
  pub fn base64url_encode(input: Uint8Array) -> Buffer {
43
- Buffer::from(
44
- base64::engine::general_purpose::URL_SAFE_NO_PAD
45
- .encode(input.as_ref())
46
- .into_bytes(),
47
- )
84
+ Buffer::from(engine(true, false).encode(input.as_ref()).into_bytes())
48
85
  }
49
86
 
50
87
  #[napi]
51
88
  pub fn base64url_decode(input: Uint8Array) -> Result<Buffer> {
52
- base64::engine::general_purpose::URL_SAFE_NO_PAD
89
+ engine(true, false)
53
90
  .decode(input.as_ref())
54
91
  .map(Buffer::from)
55
92
  .map_err(|e| Error::from_reason(e.to_string()))
@@ -76,9 +113,7 @@ pub fn base64_decode_bytes(input: &[u8], url_safe: bool, padding: bool) -> Resul
76
113
  /// one place.
77
114
  #[inline]
78
115
  pub fn base64url_encode_bytes(data: &[u8]) -> Vec<u8> {
79
- base64::engine::general_purpose::URL_SAFE_NO_PAD
80
- .encode(data)
81
- .into_bytes()
116
+ engine(true, false).encode(data).into_bytes()
82
117
  }
83
118
 
84
119
  /// Base64url (RFC 7515 §2 — URL-safe alphabet, NO padding) decode from raw
@@ -86,15 +121,26 @@ pub fn base64url_encode_bytes(data: &[u8]) -> Vec<u8> {
86
121
  #[inline]
87
122
  pub fn base64url_decode_bytes(data: &[u8]) -> Option<Vec<u8>> {
88
123
  let text = std::str::from_utf8(data).ok()?;
89
- base64::engine::general_purpose::URL_SAFE_NO_PAD
90
- .decode(text)
91
- .ok()
124
+ engine(true, false).decode(text).ok()
92
125
  }
93
126
 
94
127
  // ── hex ──────────────────────────────────────────────────────────
95
-
96
- /// Encode bytes as lowercase hex (uses the shared `HEX_LOWER` table — direct
97
- /// byte writes, no per-nibble `char` push).
128
+ //
129
+ // Lowercase hex encode + case-insensitive decode. Encode goes through the
130
+ // `faster-hex` SIMD engine (runtime-dispatched SSE→AVX2; measured 4.3-18.9x
131
+ // faster than Buffer.toString("hex") at every size 64B→1MB, replacing the
132
+ // per-byte table loop that lost 0.30x; the 1KB outlier ~19x is Bun-side). Decode has TWO tiers:
133
+ // - x86_64 + AVX2: our single-pass `hex_fast` kernel (validity mask + value
134
+ // map in one vector pass, 64 chars/iteration, scalar fallback on any
135
+ // invalid chunk). faster-hex decode does a separate validation scan +
136
+ // decode pass (~5GB/s cap vs Buffer.from(s,"hex") ~10.5GB/s); the
137
+ // single-pass kernel targets that class directly.
138
+ // - everywhere else: faster-hex decode (SSE/scalar, validated).
139
+ // Both tiers are byte-identical to the HEX_VAL_LUT semantics — verified by
140
+ // exhaustive tests (all 256 bytes × encode, all 65,536 byte-pairs × decode,
141
+ // plus a length sweep across the 64-char chunk boundary).
142
+
143
+ /// Lowercase-hex encode (SIMD via `faster-hex`).
98
144
  ///
99
145
  /// # Safety
100
146
  ///
@@ -102,26 +148,31 @@ pub fn base64url_decode_bytes(data: &[u8]) -> Option<Vec<u8>> {
102
148
  /// the built `Vec<u8>` is valid UTF-8 by construction; `from_utf8_unchecked`
103
149
  /// skips a redundant validation pass on the allocating hot path.
104
150
  pub fn hex_encode_bytes(input: &[u8]) -> String {
105
- let mut out = Vec::with_capacity(input.len() * 2);
106
- for &b in input {
107
- out.push(HEX_LOWER[(b >> 4) as usize]);
108
- out.push(HEX_LOWER[(b & 0x0f) as usize]);
109
- }
110
- // SAFETY: every byte pushed is an ASCII hex digit from `HEX_LOWER`.
151
+ let mut out = vec![0u8; input.len() * 2];
152
+ // faster-hex encode cannot fail on an exactly-sized buffer.
153
+ faster_hex::hex_encode(input, &mut out).expect("hex encode: exact-size output buffer");
154
+ // SAFETY: every byte written is an ASCII hex digit from `HEX_LOWER`.
111
155
  unsafe { String::from_utf8_unchecked(out) }
112
156
  }
113
157
 
114
- /// Decode lowercase/uppercase hex to bytes.
158
+ /// Decode lowercase/uppercase hex to bytes. On x86_64 + AVX2 this uses the
159
+ /// single-pass `hex_fast` kernel; elsewhere the validated `faster-hex` engine
160
+ /// — both reject `[^0-9a-fA-F]` exactly like `hex_val`.
115
161
  pub fn hex_decode_bytes(input: &[u8]) -> std::result::Result<Vec<u8>, &'static str> {
116
162
  if !input.len().is_multiple_of(2) {
117
163
  return Err("odd hex length");
118
164
  }
119
- let mut out = Vec::with_capacity(input.len() / 2);
120
- for i in (0..input.len()).step_by(2) {
121
- let hi = hex_val(input[i]).ok_or("invalid hex digit")?;
122
- let lo = hex_val(input[i + 1]).ok_or("invalid hex digit")?;
123
- out.push((hi << 4) | lo);
165
+ let mut out = vec![0u8; input.len() / 2];
166
+ #[cfg(target_arch = "x86_64")]
167
+ {
168
+ if std::arch::is_x86_feature_detected!("avx2") {
169
+ // SAFETY: AVX2 runtime-detected; `input` even, `out` exactly
170
+ // `input.len() / 2` (both established above).
171
+ unsafe { hex_fast::decode(input, &mut out) }?;
172
+ return Ok(out);
173
+ }
124
174
  }
175
+ faster_hex::hex_decode(input, &mut out).map_err(|_| "invalid hex digit")?;
125
176
  Ok(out)
126
177
  }
127
178
 
@@ -144,8 +195,8 @@ pub fn hex_decode(input: Uint8Array) -> Result<Buffer> {
144
195
  // a fresh Vec + napi Buffer per call. They error on a buffer that is too small;
145
196
  // input/output overlap is handled by `crate::util::run_packed_into`.
146
197
 
147
- /// Lowercase-hex encode directly into `out`. Returns bytes written
148
- /// (`input.len() * 2`). Errors if `out` is too small.
198
+ /// Lowercase-hex encode directly into `out` (SIMD via `faster-hex`). Returns
199
+ /// bytes written (`input.len() * 2`). Errors if `out` is too small.
149
200
  #[inline]
150
201
  pub fn hex_encode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
151
202
  let needed = input
@@ -155,13 +206,12 @@ pub fn hex_encode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
155
206
  if out.len() < needed {
156
207
  return Err(Error::from_reason("hex encode: output buffer too small"));
157
208
  }
158
- let mut pos = 0usize;
159
- for &b in input {
160
- out[pos] = HEX_LOWER[(b >> 4) as usize];
161
- out[pos + 1] = HEX_LOWER[(b & 0x0f) as usize];
162
- pos += 2;
163
- }
164
- Ok(pos)
209
+ // Slice to the exact size so the pipeline never sees a ragged tail: the
210
+ // caller-sized-buffer contract is preserved (error above), and the engine
211
+ // fills `needed` bytes exactly.
212
+ let exact = &mut out[..needed];
213
+ faster_hex::hex_encode(input, exact).expect("hex encode: exact-size output buffer");
214
+ Ok(needed)
165
215
  }
166
216
 
167
217
  #[napi]
@@ -169,8 +219,12 @@ pub fn hex_encode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32>
169
219
  crate::util::run_packed_into(&input, &mut output, hex_encode_into_slice)
170
220
  }
171
221
 
172
- /// Hex-decode directly into `out`. Returns bytes written (`input.len() / 2`).
173
- /// Errors on odd length, invalid digits, or an output buffer that is too small.
222
+ /// Hex-decode directly into `out` — case-insensitive, rejects
223
+ /// `[^0-9a-fA-F]` exactly like `hex_val`. Returns bytes written
224
+ /// (`input.len() / 2`). Errors on odd length, invalid digits, or an output
225
+ /// buffer that is too small. On x86_64 with AVX2 this dispatches to the
226
+ /// single-pass `hex_fast` kernel; everywhere else it uses the validated
227
+ /// `faster-hex` engine — both byte-identical to the scalar LUT semantics.
174
228
  #[inline]
175
229
  pub fn hex_decode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
176
230
  if !input.len().is_multiple_of(2) {
@@ -180,14 +234,27 @@ pub fn hex_decode_into_slice(input: &[u8], out: &mut [u8]) -> Result<usize> {
180
234
  if out.len() < needed {
181
235
  return Err(Error::from_reason("hex decode: output buffer too small"));
182
236
  }
183
- let mut pos = 0usize;
184
- for i in (0..input.len()).step_by(2) {
185
- let hi = hex_val(input[i]).ok_or_else(|| Error::from_reason("invalid hex digit"))?;
186
- let lo = hex_val(input[i + 1]).ok_or_else(|| Error::from_reason("invalid hex digit"))?;
187
- out[pos] = (hi << 4) | lo;
188
- pos += 1;
237
+ hex_decode_into_slice_impl(input, &mut out[..needed])
238
+ }
239
+
240
+ #[cfg(target_arch = "x86_64")]
241
+ #[inline]
242
+ fn hex_decode_into_slice_impl(input: &[u8], out: &mut [u8]) -> Result<usize> {
243
+ if std::arch::is_x86_feature_detected!("avx2") {
244
+ // SAFETY: AVX2 runtime-detected; `input` even and `out` exactly
245
+ // `input.len() / 2` (checked above).
246
+ unsafe { hex_fast::decode(input, out) }.map_err(Error::from_reason)
247
+ } else {
248
+ faster_hex::hex_decode(input, out).map_err(|_| Error::from_reason("invalid hex digit"))?;
249
+ Ok(out.len())
189
250
  }
190
- Ok(pos)
251
+ }
252
+
253
+ #[cfg(not(target_arch = "x86_64"))]
254
+ #[inline]
255
+ fn hex_decode_into_slice_impl(input: &[u8], out: &mut [u8]) -> Result<usize> {
256
+ faster_hex::hex_decode(input, out).map_err(|_| Error::from_reason("invalid hex digit"))?;
257
+ Ok(out.len())
191
258
  }
192
259
 
193
260
  #[napi]
@@ -195,6 +262,113 @@ pub fn hex_decode_into(input: Uint8Array, mut output: Uint8Array) -> Result<u32>
195
262
  crate::util::run_packed_into(&input, &mut output, hex_decode_into_slice)
196
263
  }
197
264
 
265
+ /// Single-pass AVX2 hex decode — the accelerated decode tier on x86_64 (see
266
+ /// the hex section header). Decodes 64 hex chars (32 output bytes) per
267
+ /// iteration, computing the validity mask and the hex-digit values in ONE
268
+ /// vector pass. Any chunk containing a non-hex byte rejects the whole input
269
+ /// (byte-identical to the HEX_VAL_LUT scalar reject-everywhere semantics —
270
+ /// no partial writes). The remainder (< 64 chars) is decoded by the validated
271
+ /// `faster-hex` engine, so error behavior and output are identical everywhere.
272
+ ///
273
+ /// faster-hex decode does a separate validation scan + decode pass (~5GB/s
274
+ /// cap measured vs Buffer.from(hex) ~10.5GB/s); this kernel is single-pass
275
+ /// and targets that gap.
276
+ #[cfg(target_arch = "x86_64")]
277
+ pub mod hex_fast {
278
+ use std::arch::x86_64::*;
279
+
280
+ /// Decode `chars[0..64]` → `out[0..32]`. Returns false when any of the 64
281
+ /// chars is not `[0-9a-fA-F]`; on false, `out` is left untouched so
282
+ /// callers can error without partial writes.
283
+ #[target_feature(enable = "avx2")]
284
+ unsafe fn decode_chunk(chars: *const u8, out: *mut u8) -> bool {
285
+ let lo = _mm256_loadu_si256(chars as *const __m256i); // chars 0..31
286
+ let hi = _mm256_loadu_si256(chars.add(32) as *const __m256i); // chars 32..63
287
+
288
+ // Lowercase for the letter test ('A'-'F' | 0x20 == 'a'-'f').
289
+ let loo = _mm256_or_si256(lo, _mm256_set1_epi8(0x20));
290
+ let hio = _mm256_or_si256(hi, _mm256_set1_epi8(0x20));
291
+
292
+ // Validity per byte (signed compares; all bounds are < 0x80 so signed
293
+ // and unsigned agree for the printable range, and bytes >= 0x80 fail
294
+ // both bounds): (b in '0'..='9') | (b|0x20 in 'a'..='f').
295
+ let lo_digit = _mm256_and_si256(
296
+ _mm256_cmpgt_epi8(loo, _mm256_set1_epi8((b'0' - 1) as i8)),
297
+ _mm256_cmpgt_epi8(_mm256_set1_epi8((b'9' + 1) as i8), loo),
298
+ );
299
+ let lo_letter = _mm256_and_si256(
300
+ _mm256_cmpgt_epi8(loo, _mm256_set1_epi8((b'a' - 1) as i8)),
301
+ _mm256_cmpgt_epi8(_mm256_set1_epi8((b'f' + 1) as i8), loo),
302
+ );
303
+ let hi_digit = _mm256_and_si256(
304
+ _mm256_cmpgt_epi8(hio, _mm256_set1_epi8((b'0' - 1) as i8)),
305
+ _mm256_cmpgt_epi8(_mm256_set1_epi8((b'9' + 1) as i8), hio),
306
+ );
307
+ let hi_letter = _mm256_and_si256(
308
+ _mm256_cmpgt_epi8(hio, _mm256_set1_epi8((b'a' - 1) as i8)),
309
+ _mm256_cmpgt_epi8(_mm256_set1_epi8((b'f' + 1) as i8), hio),
310
+ );
311
+ let lo_valid = _mm256_or_si256(lo_digit, lo_letter);
312
+ let hi_valid = _mm256_or_si256(hi_digit, hi_letter);
313
+ if _mm256_movemask_epi8(lo_valid) != -1 || _mm256_movemask_epi8(hi_valid) != -1 {
314
+ return false;
315
+ }
316
+
317
+ // Value: (b|0x20) - '0' gives 0-9 for digits and 49-54 for a-f;
318
+ // subtract 39 where it is a letter so a-f map to 10-15.
319
+ let lo_letter_sel = _mm256_cmpgt_epi8(loo, _mm256_set1_epi8((b'a' - 1) as i8));
320
+ let hi_letter_sel = _mm256_cmpgt_epi8(hio, _mm256_set1_epi8((b'a' - 1) as i8));
321
+ let v_lo = _mm256_sub_epi8(
322
+ _mm256_sub_epi8(loo, _mm256_set1_epi8(b'0' as i8)),
323
+ _mm256_and_si256(lo_letter_sel, _mm256_set1_epi8(39)),
324
+ );
325
+ let v_hi = _mm256_sub_epi8(
326
+ _mm256_sub_epi8(hio, _mm256_set1_epi8(b'0' as i8)),
327
+ _mm256_and_si256(hi_letter_sel, _mm256_set1_epi8(39)),
328
+ );
329
+
330
+ // Pack adjacent pairs (v0, v1) -> (v0 << 4) | v1 with pmaddubsw
331
+ // (unsigned v * signed pattern [16, 1] per pair, summed into u16).
332
+ let pat = _mm256_set1_epi16(0x0110); // little-endian bytes: 0x10=16, 0x01=1
333
+ let p_lo = _mm256_maddubs_epi16(v_lo, pat); // u16 lanes: out[0..8] (half0), out[8..16] (half1)
334
+ let p_hi = _mm256_maddubs_epi16(v_hi, pat); // u16 lanes: out[16..24] (half0), out[24..32] (half1)
335
+
336
+ // packus_epi16 lays halves side by side per 128-bit lane:
337
+ // lane0 = out[0..8] + out[16..24] lane1 = out[8..16] + out[24..32]
338
+ // so a 64-bit lane permute [0, 2, 1, 3] restores the byte order.
339
+ let packed = _mm256_packus_epi16(p_lo, p_hi);
340
+ let ordered = _mm256_permute4x64_epi64(packed, 0b11_01_10_00);
341
+ _mm256_storeu_si256(out as *mut __m256i, ordered);
342
+ true
343
+ }
344
+
345
+ /// Single-pass decode of `input` into `out`. Preconditions (checked by the
346
+ /// callers): `input.len()` is even and `out.len() == input.len()/2`.
347
+ /// Returns `Ok(limit / 2)` or `Err("invalid hex digit")`.
348
+ ///
349
+ /// # Safety
350
+ /// The caller must have runtime-detected AVX2
351
+ /// (`is_x86_feature_detected!("avx2")`) and must uphold the length
352
+ /// preconditions above.
353
+ #[target_feature(enable = "avx2")]
354
+ pub unsafe fn decode(input: &[u8], out: &mut [u8]) -> std::result::Result<usize, &'static str> {
355
+ let limit = input.len();
356
+ let mut i = 0usize;
357
+ while i + 64 <= limit {
358
+ if !decode_chunk(input.as_ptr().add(i), out.as_mut_ptr().add(i / 2)) {
359
+ return Err("invalid hex digit");
360
+ }
361
+ i += 64;
362
+ }
363
+ if i < limit {
364
+ // Tail (a multiple-of-2 remainder < 64 chars): validated engine.
365
+ faster_hex::hex_decode(&input[i..], &mut out[i / 2..limit / 2])
366
+ .map_err(|_| "invalid hex digit")?;
367
+ }
368
+ Ok(limit / 2)
369
+ }
370
+ }
371
+
198
372
  /// Pure core: base64-encode `input` into `out` (zero-alloc `encode_slice`).
199
373
  /// Returns bytes written; errors if `out` is too small. Shared by the `_into`
200
374
  /// napi path and the pure-core unit tests.
@@ -255,10 +429,11 @@ pub fn base64_decode_into(
255
429
 
256
430
  /// Higher-order instance: the alphabet/padding engine is selected ONCE at
257
431
  /// construction and stored concretely, so `encode`/`decode` never re-run the
258
- /// 4-way engine match on the per-call path.
432
+ /// 4-way engine match on the per-call path. On x86_64/aarch64 this holds the
433
+ /// SIMD runtime-dispatched engine, so large payloads also get AVX2/NEON.
259
434
  #[napi]
260
435
  pub struct Base64Codec {
261
- engine: base64::engine::general_purpose::GeneralPurpose,
436
+ engine: engine_sel::Engine,
262
437
  }
263
438
 
264
439
  #[napi]
@@ -335,6 +510,81 @@ mod tests {
335
510
  assert_eq!(enc.as_ref(), b"-w");
336
511
  }
337
512
 
513
+ // ── SIMD engine (x86_64 / aarch64: AVX2 / NEON kernels) ──
514
+ //
515
+ // The SIMD engine must be a drop-in for the scalar one: byte-identical
516
+ // output, identical validity/padding errors, on payloads both above the
517
+ // SIMD thresholds (decode ≥ 64 B, encode ≥ 128 B) and below them.
518
+
519
+ fn deterministic(n: usize) -> Vec<u8> {
520
+ (0..n)
521
+ .map(|i| (i.wrapping_mul(31) ^ (i >> 3)) as u8)
522
+ .collect()
523
+ }
524
+
525
+ #[test]
526
+ fn simd_large_payload_roundtrip_into_slice() {
527
+ // 16 KiB: well above both SIMD thresholds — exercises the wide kernels
528
+ // end-to-end and must round-trip byte-identically.
529
+ let data = deterministic(16 * 1024);
530
+ let mut enc = vec![0u8; data.len().div_ceil(3) * 4];
531
+ let w = base64_encode_into_slice(&data, &mut enc, false, true).unwrap();
532
+ assert_eq!(w, enc.len());
533
+ let mut dec = vec![0u8; data.len()];
534
+ let m = base64_decode_into_slice(&enc, &mut dec, false, true).unwrap();
535
+ assert_eq!(&dec[..m], &data[..]);
536
+ }
537
+
538
+ #[test]
539
+ fn simd_output_byte_identical_to_scalar() {
540
+ // Large payload: SIMD encode/decode must match the scalar engines
541
+ // exactly (alphabet + padding wire contract unchanged).
542
+ let data = deterministic(2048);
543
+ // standard w/ padding
544
+ let mut enc = vec![0u8; data.len().div_ceil(3) * 4];
545
+ let w = base64_encode_into_slice(&data, &mut enc, false, true).unwrap();
546
+ let scalar_exp = base64::engine::general_purpose::STANDARD.encode(&data);
547
+ assert_eq!(&enc[..w], scalar_exp.as_bytes());
548
+ // url-safe, no padding
549
+ let mut encu = vec![0u8; data.len().div_ceil(3) * 4];
550
+ let wu = base64_encode_into_slice(&data, &mut encu, true, false).unwrap();
551
+ let scalar_expu = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&data);
552
+ assert_eq!(&encu[..wu], scalar_expu.as_bytes());
553
+ // decode path parity
554
+ let mut dec = vec![0u8; data.len()];
555
+ let m = base64_decode_into_slice(scalar_exp.as_bytes(), &mut dec, false, true).unwrap();
556
+ assert_eq!(&dec[..m], &data[..]);
557
+ let mut decu = vec![0u8; data.len()];
558
+ let mu = base64_decode_into_slice(scalar_expu.as_bytes(), &mut decu, true, false).unwrap();
559
+ assert_eq!(&decu[..mu], &data[..]);
560
+ }
561
+
562
+ #[test]
563
+ fn simd_large_decode_rejects_invalid() {
564
+ // Invalid base64 symbol mid-buffer above the SIMD threshold: the wide
565
+ // kernel breaks out and the scalar decoder reports the error — parity
566
+ // with the scalar engine's rejection.
567
+ let mut bad = vec![b'A'; 4096];
568
+ bad[2048] = b'!';
569
+ let mut out = vec![0u8; 4096];
570
+ assert!(base64_decode_into_slice(&bad, &mut out, false, true).is_err());
571
+ let scalar = base64::engine::general_purpose::STANDARD.decode(bad.as_slice());
572
+ assert!(scalar.is_err());
573
+ }
574
+
575
+ #[test]
576
+ fn simd_small_payload_matches_scalar() {
577
+ // Below the SIMD thresholds the engine is scalar internally; output
578
+ // and errors must match the preconfigured engines.
579
+ let data = b"hello world!";
580
+ let mut enc = [0u8; 32];
581
+ let w = base64_encode_into_slice(data, &mut enc, false, true).unwrap();
582
+ assert_eq!(&enc[..w], b"aGVsbG8gd29ybGQh");
583
+ let mut dec = [0u8; 16];
584
+ let m = base64_decode_into_slice(b"aGVsbG8gd29ybGQh", &mut dec, false, true).unwrap();
585
+ assert_eq!(&dec[..m], data);
586
+ }
587
+
338
588
  // ── reusable-output (_into) variants ──
339
589
 
340
590
  #[test]
@@ -384,6 +634,171 @@ mod tests {
384
634
  assert!(hex_decode_into_slice(b"0001020304", &mut out).is_err());
385
635
  }
386
636
 
637
+ // ── hex SIMD engine (faster-hex: runtime-dispatched SSE→AVX2) ──
638
+ //
639
+ // faster-hex must be a drop-in for the previous HEX_VAL_LUT scalar loops:
640
+ // byte-identical encode output, identical accept/reject + values on
641
+ // decode, at lengths both below and above the SIMD chunk boundaries
642
+ // (pshufb processes 16 bytes/iteration, so 15/16/17, 31/32/33, … are the
643
+ // straddle cases that would expose a broken fast path).
644
+
645
+ /// Reference lowercase-hex encode (the previous per-byte LUT loop).
646
+ fn ref_hex_encode(input: &[u8]) -> Vec<u8> {
647
+ let hex = b"0123456789abcdef";
648
+ let mut out = Vec::with_capacity(input.len() * 2);
649
+ for &b in input {
650
+ out.push(hex[(b >> 4) as usize]);
651
+ out.push(hex[(b & 0x0f) as usize]);
652
+ }
653
+ out
654
+ }
655
+
656
+ /// Reference hex-digit value (the previous HEX_VAL_LUT semantics).
657
+ fn ref_hex_val(b: u8) -> Option<u8> {
658
+ match b {
659
+ b'0'..=b'9' => Some(b - b'0'),
660
+ b'a'..=b'f' => Some(b - b'a' + 10),
661
+ b'A'..=b'F' => Some(b - b'A' + 10),
662
+ _ => None,
663
+ }
664
+ }
665
+
666
+ #[test]
667
+ fn hex_simd_encode_parity_all_lengths_and_sizes() {
668
+ // Every length 0..=96 (covers every pshufb chunk boundary twice) plus
669
+ // larger payloads that exercise the wide kernels end-to-end.
670
+ let mut lens: Vec<usize> = (0..=96).collect();
671
+ lens.extend([128, 255, 256, 511, 512, 1024, 16 * 1024, 256 * 1024]);
672
+ for n in lens {
673
+ let data = deterministic(n);
674
+ let expect = ref_hex_encode(&data);
675
+ // allocating path
676
+ assert_eq!(hex_encode_bytes(&data).as_bytes(), &expect[..], "len {n}");
677
+ // into-slice path (exact-size buffer, like the C-ABI caller)
678
+ let mut out = vec![0u8; n * 2];
679
+ let w = hex_encode_into_slice(&data, &mut out).unwrap();
680
+ assert_eq!(w, n * 2);
681
+ assert_eq!(&out[..w], &expect[..], "len {n}");
682
+ }
683
+ }
684
+
685
+ #[test]
686
+ fn hex_simd_decode_exhaustive_pair_matrix() {
687
+ // All 65,536 two-char combos: accept/reject and decoded value must be
688
+ // identical to the reference LUT semantics (case-insensitive digits,
689
+ // everything else rejected). A SIMD engine with a divergent validity
690
+ // mask fails here.
691
+ for hi in 0u16..=255 {
692
+ for lo in 0u16..=255 {
693
+ let pair = [hi as u8, lo as u8];
694
+ let want = match (ref_hex_val(pair[0]), ref_hex_val(pair[1])) {
695
+ (Some(h), Some(l)) => Some((h << 4) | l),
696
+ _ => None,
697
+ };
698
+ let mut out = [0u8; 1];
699
+ let got = hex_decode_into_slice(&pair, &mut out);
700
+ match want {
701
+ Some(v) => {
702
+ assert!(got.is_ok(), "{pair:02x?}: expected OK");
703
+ assert_eq!(out[0], v, "{pair:02x?}: value");
704
+ }
705
+ None => assert!(got.is_err(), "{pair:02x?}: expected reject"),
706
+ }
707
+ }
708
+ }
709
+ }
710
+
711
+ #[test]
712
+ fn hex_simd_large_roundtrip() {
713
+ // 2 MiB deterministic payload: SIMD encode → SIMD decode must be the
714
+ // identity, and the decoded bytes must match the source exactly.
715
+ let data = deterministic(2 * 1024 * 1024);
716
+ let mut enc = vec![0u8; data.len() * 2];
717
+ let w = hex_encode_into_slice(&data, &mut enc).unwrap();
718
+ assert_eq!(w, data.len() * 2);
719
+ let mut dec = vec![0u8; data.len()];
720
+ let m = hex_decode_into_slice(&enc, &mut dec).unwrap();
721
+ assert_eq!(m, data.len());
722
+ assert_eq!(&dec[..], &data[..]);
723
+ // uppercase decodes to the same bytes (case-insensitive)
724
+ for b in enc.iter_mut() {
725
+ if b.is_ascii_lowercase() {
726
+ *b = b.to_ascii_uppercase();
727
+ }
728
+ }
729
+ let mut dec2 = vec![0u8; data.len()];
730
+ let m2 = hex_decode_into_slice(&enc, &mut dec2).unwrap();
731
+ assert_eq!(&dec2[..m2], &data[..]);
732
+ }
733
+
734
+ #[test]
735
+ fn hex_simd_large_decode_rejects_invalid_tail() {
736
+ // An invalid char at the very END of a 256 KiB string: the SIMD tail
737
+ // handler must still reject (parity with the scalar loop's rejection
738
+ // at any position).
739
+ let mut hex = Vec::with_capacity(256 * 1024);
740
+ for _ in 0..(128 * 1024) {
741
+ hex.extend_from_slice(b"ab");
742
+ }
743
+ hex.push(b'g'); // 'g' is not a hex digit → odd+invalid
744
+ let mut out = vec![0u8; 128 * 1024];
745
+ assert!(hex_decode_into_slice(&hex, &mut out).is_err());
746
+
747
+ // Even-length with a bad char one past the SIMD chunk boundary.
748
+ let mut hex2 = hex[..hex.len() - 1].to_vec(); // even, all "ab..."
749
+ hex2[200_001] = b'z';
750
+ assert!(hex_decode_into_slice(&hex2, &mut out).is_err());
751
+ }
752
+
753
+ #[test]
754
+ fn hex_simd_decode_chunk_boundary_sweep() {
755
+ // Every length 0..=160 crosses the AVX2 kernel's 64-char chunk
756
+ // boundary several times: valid inputs must decode to the reference
757
+ // bytes, and an invalid char must be rejected no matter which bucket
758
+ // it lands in (chunk body, chunk boundary, or the < 64-char tail).
759
+ // This is the direct guard on the single-pass kernel's chunk loop +
760
+ // tail handoff (the exhaustive pair matrix above covers 2-char inputs,
761
+ // which only ever exercise the tail path).
762
+ let mut lens: Vec<usize> = (0..=160).collect();
763
+ lens.extend([192, 200, 512, 4096]);
764
+ for n in lens {
765
+ let even = n - n % 2; // even number of HEX CHARS in the input
766
+ let expect: Vec<u8> = (0..even / 2)
767
+ .map(|i| (i.wrapping_mul(13) + 7) as u8)
768
+ .collect();
769
+ let hex: String = expect.iter().map(|b| format!("{b:02x}")).collect();
770
+ assert_eq!(hex.len(), even);
771
+ let mut out = vec![0u8; even / 2];
772
+ let w = hex_decode_into_slice(hex.as_bytes(), &mut out).unwrap();
773
+ assert_eq!(&out[..w], &expect[..], "len {even}");
774
+ // Reject an invalid char at one position per bucket.
775
+ for &pos in &[
776
+ 0usize,
777
+ 1,
778
+ 14,
779
+ 30,
780
+ 31,
781
+ 32,
782
+ 62,
783
+ 63,
784
+ 64,
785
+ 65,
786
+ even.saturating_sub(2),
787
+ even.saturating_sub(1),
788
+ ] {
789
+ if pos < even {
790
+ let mut bad = hex.clone().into_bytes();
791
+ bad[pos] = b'g'; // 'g' is never a hex digit
792
+ let mut o = vec![0u8; even / 2];
793
+ assert!(
794
+ hex_decode_into_slice(&bad, &mut o).is_err(),
795
+ "len {even} pos {pos}"
796
+ );
797
+ }
798
+ }
799
+ }
800
+ }
801
+
387
802
  #[test]
388
803
  fn base64_encode_into_reports_length() {
389
804
  let data = b"hello world!";
@@ -137,4 +137,72 @@ mod tests {
137
137
  xxh3(Uint8Array::new(input.to_vec()))
138
138
  );
139
139
  }
140
+
141
+ /// Known-value xxh3-64 vectors that LOCK the spec output across both the
142
+ /// short-input (< 240 B) and long-input (>= 240 B) regimes of the engine.
143
+ /// Generated with `Bun.hash.xxHash3` (independent spec-compliant
144
+ /// implementation). The 240/241 pair guards the engine's internal
145
+ /// threshold: an implementation or length-handling regression that
146
+ /// diverges only above it fails here even though small-input tests pass.
147
+ /// (Perf note: the scalar core is FLAT ~30GB/s 64B→1MB — measured, no
148
+ /// size pathology; see docs/bun-builtins-decision-matrix.md for why Bun
149
+ /// still wins the consumer under Bun.)
150
+ #[test]
151
+ fn xxh3_vectors_lock_spec_across_simd_threshold() {
152
+ // input: (i * 31 + 5) % 256 for i in 0..n (deterministic)
153
+ const V: &[(usize, u64)] = &[
154
+ (0, 0x2d06_8005_38d3_94c2),
155
+ (1, 0x929e_358d_27ae_3ee2),
156
+ (2, 0xf8a9_b018_ddb0_dc1a),
157
+ (3, 0xea4f_94f7_8c69_f54e),
158
+ (4, 0xc674_aba0_6059_22b7),
159
+ (5, 0x988e_18b6_a130_77ac),
160
+ (6, 0x19e7_8a37_d9eb_8365),
161
+ (7, 0x4d32_b9b4_d45d_9319),
162
+ (8, 0xad43_7729_e900_e521),
163
+ (9, 0x2f54_64ee_70cc_cff9),
164
+ (10, 0x97a1_114c_1cbf_45ae),
165
+ (11, 0x2933_959d_e03b_8ce8),
166
+ (12, 0x5cba_0687_e71c_0f12),
167
+ (13, 0x5a27_1d93_20eb_cb8a),
168
+ (14, 0xeb81_cf37_8320_b598),
169
+ (15, 0xec7b_ee5b_893b_d5d5),
170
+ (16, 0x0903_f58f_8225_3d8c),
171
+ (17, 0x48a3_8615_d4ee_fb8b),
172
+ (18, 0xb871_d958_cc11_dfa4),
173
+ (19, 0xc6ed_7037_b89b_d206),
174
+ (23, 0xd410_62e4_0171_ac46),
175
+ (31, 0x4334_b263_9627_f197),
176
+ (32, 0xebeb_3ba5_7ab1_edca),
177
+ (33, 0x7ff1_7572_bd9e_70a9),
178
+ (63, 0x504d_3e53_12aa_2db1),
179
+ (64, 0xd8cc_034e_d06e_92f3),
180
+ (65, 0x73c9_73e2_4eda_1cdc),
181
+ (95, 0x45f6_3f33_cade_8c19),
182
+ (96, 0x4c46_e758_08f2_74e9),
183
+ (127, 0x1ed1_3eff_d01b_9402),
184
+ (128, 0x05a1_567d_530f_961c),
185
+ (191, 0xba5e_7dfd_d63d_7785),
186
+ (192, 0x999d_b9f2_4d8c_ffcf),
187
+ (238, 0xda80_d2d5_c7b9_397e),
188
+ (239, 0xb0ca_9389_9a38_51d5),
189
+ (240, 0xc502_31c4_c4c8_6a63),
190
+ (241, 0xa7dc_042e_5a9f_6aa6),
191
+ (255, 0x62c8_1de1_9704_658e),
192
+ (256, 0x84f8_cedc_d725_6d1a),
193
+ (512, 0xd4d7_d053_e788_d9ef),
194
+ (1024, 0xdb4a_2f32_c2a5_bc38),
195
+ (16384, 0xf04a_c437_8b21_4439),
196
+ (262144, 0x6c36_c9dc_9aaf_20f0),
197
+ ];
198
+ let input =
199
+ |n: usize| -> Vec<u8> { (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect() };
200
+ for (n, want) in V {
201
+ assert_eq!(
202
+ fast_hash_bytes(&input(*n)),
203
+ *want,
204
+ "xxh3({n} bytes) — engine regime mismatch",
205
+ );
206
+ }
207
+ }
140
208
  }
@@ -40,10 +40,14 @@ pub fn encode_event_size(
40
40
  if let Some(retry) = retry {
41
41
  need += 8 + u64_digits(retry); // "retry: " + digits + "\n"
42
42
  }
43
- // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves,
44
- // then the terminating blank `\n`.
45
- let line_count = data.iter().filter(|&&b| b == b'\n').count() + 1;
46
- need + line_count * 7 + data.len() + 1
43
+ // One `data: ` (6) + `\n` (1) per input line, the line bytes themselves
44
+ // (SEGMENTS EXCLUDE the `\n` separators — sum of segment lengths =
45
+ // `data.len() - (line_count - 1)`), then the terminating blank `\n`.
46
+ // Line boundaries found via SIMD memchr — a scalar `filter().count()`
47
+ // pass over the full payload was ~1 cycle/byte (~650ns/KB steady state),
48
+ // collapsing large encodes to 0.10× of JS.
49
+ let line_count = memchr::memchr_iter(b'\n', data).count() + 1;
50
+ need + line_count * 7 + data.len() - (line_count - 1) + 1
47
51
  }
48
52
 
49
53
  /// Encode one SSE event into `out`, returning bytes written. Zero-alloc (no
@@ -98,7 +102,23 @@ pub fn encode_event_into_slice(
98
102
  out[w] = b'\n';
99
103
  w += 1;
100
104
  }
101
- for line in data.split(|&b| b == b'\n') {
105
+ // One `data: ` line per input line: SIMD memchr finds the `\n` boundaries
106
+ // (no scalar split() re-scan of the whole payload), copying each run.
107
+ // `data.split('\n')` semantics are preserved exactly: the tail after the
108
+ // last `\n` is emitted even when empty.
109
+ let mut line_start = 0usize;
110
+ for sep in memchr::memchr_iter(b'\n', data) {
111
+ let line = &data[line_start..sep];
112
+ out[w..w + 6].copy_from_slice(b"data: ");
113
+ w += 6;
114
+ out[w..w + line.len()].copy_from_slice(line);
115
+ w += line.len();
116
+ out[w] = b'\n';
117
+ w += 1;
118
+ line_start = sep + 1;
119
+ }
120
+ {
121
+ let line = &data[line_start..];
102
122
  out[w..w + 6].copy_from_slice(b"data: ");
103
123
  w += 6;
104
124
  out[w..w + line.len()].copy_from_slice(line);
@@ -211,4 +231,57 @@ mod tests {
211
231
  let mut out = [0u8; 3];
212
232
  assert!(encode_event_into_slice(Some("update"), b"payload", None, None, &mut out).is_err());
213
233
  }
234
+
235
+ #[test]
236
+ fn large_multiline_matches_allocating() {
237
+ // 4k lines x 96-byte data — spans well past any SIMD threshold.
238
+ let mut data = Vec::with_capacity(4 * 1024 * 96);
239
+ for i in 0..4 * 1024 {
240
+ data.extend_from_slice(format!("line-{i} ").as_bytes());
241
+ data.extend_from_slice(b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // 55
242
+ data.push(b'\n');
243
+ }
244
+ let expected = encode_event(None, &data, None, None);
245
+ assert!(
246
+ expected.len() > 256 * 1024,
247
+ "test payload unexpectedly small: {}",
248
+ expected.len()
249
+ );
250
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
251
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
252
+ assert_eq!(w, expected.len());
253
+ assert_eq!(&out[..w], &expected[..]);
254
+ }
255
+
256
+ #[test]
257
+ fn single_large_line_scales_too() {
258
+ // One 1MB line — the old scalar split() path cost ~300us here.
259
+ let mut data = vec![b'a'; 1 << 20];
260
+ data.push(b'\n');
261
+ let expected = encode_event(None, &data, None, None);
262
+ let mut out = vec![0u8; encode_event_size(None, &data, None, None)];
263
+ let w = encode_event_into_slice(None, &data, None, None, &mut out).unwrap();
264
+ assert_eq!(w, expected.len());
265
+ assert_eq!(&out[..w], &expected[..]);
266
+ }
267
+
268
+ #[test]
269
+ fn trailing_newline_emits_empty_tail_line() {
270
+ // split() semantics: "a\n" -> ["a", ""] -> one empty data: line.
271
+ assert_eq!(
272
+ encode_event(None, b"hello\n", None, None),
273
+ b"data: hello\ndata: \n\n"
274
+ );
275
+ assert_eq!(encode_event(None, b"\n", None, None), b"data: \ndata: \n\n");
276
+ assert_eq!(encode_event(None, b"", None, None), b"data: \n\n");
277
+ }
278
+
279
+ #[test]
280
+ fn embedded_crlf_kept_in_line() {
281
+ // Only \n splits lines; \r stays in the line bytes.
282
+ assert_eq!(
283
+ encode_event(None, b"a\r\nb", None, None),
284
+ b"data: a\r\ndata: b\n\n"
285
+ );
286
+ }
214
287
  }
package/rust/util/text.rs CHANGED
@@ -14,23 +14,50 @@ use napi_derive::napi;
14
14
  /// [`regex_escape_write`].
15
15
  const REGEX_META: &[u8; 14] = b"\\.*+?^${}()|[]";
16
16
 
17
+ /// 256-entry membership lookup for [`REGEX_META`] — one table load per byte.
18
+ /// The previous per-byte `REGEX_META.contains(&c)` did a linear 14-element
19
+ /// scan PER byte (O(n·14)); a 16KB payload took ~100µs (~160MB/s) and the
20
+ /// win margin vs JS regex-replace collapsed with size. The LUT makes the hot
21
+ /// loop branch-predictable (~1 load/byte), and safe runs are copied in bulk
22
+ /// (memcpy) instead of one push per byte.
23
+ const REGEX_META_LUT: [u8; 256] = {
24
+ let mut t = [0u8; 256];
25
+ let mut i = 0usize;
26
+ while i < REGEX_META.len() {
27
+ t[REGEX_META[i] as usize] = 1;
28
+ i += 1;
29
+ }
30
+ t
31
+ };
32
+
33
+ #[inline(always)]
34
+ fn is_meta(b: u8) -> bool {
35
+ REGEX_META_LUT[b as usize] != 0
36
+ }
37
+
17
38
  /// Append the RegExp-escaped form of `input` to `out`. ASCII-only transform
18
39
  /// (backslash before metachar bytes) — multi-byte UTF-8 sequences pass
19
40
  /// through byte-exact because no UTF-8 continuation byte is a metachar.
41
+ /// Safe runs are bulk-copied (memcpy) between metachars, so throughput scales
42
+ /// with memory bandwidth, not the metachar-set size.
20
43
  pub fn regex_escape_into(input: &[u8], out: &mut Vec<u8>) {
21
- for &c in input {
22
- if REGEX_META.contains(&c) {
44
+ let mut run_start = 0usize;
45
+ for (i, &c) in input.iter().enumerate() {
46
+ if is_meta(c) {
47
+ out.extend_from_slice(&input[run_start..i]);
23
48
  out.push(b'\\');
49
+ out.push(c);
50
+ run_start = i + 1;
24
51
  }
25
- out.push(c);
26
52
  }
53
+ out.extend_from_slice(&input[run_start..]);
27
54
  }
28
55
 
29
56
  /// Exact escaped length (metachar count + input length) — lets callers size
30
57
  /// output buffers without running the escape twice.
31
58
  #[inline]
32
59
  pub fn regex_escape_len(input: &[u8]) -> usize {
33
- input.len() + input.iter().filter(|b| REGEX_META.contains(b)).count()
60
+ input.len() + input.iter().filter(|&&b| is_meta(b)).count()
34
61
  }
35
62
 
36
63
  /// Write the escaped form of `input` into `out` (which must be at least
@@ -38,15 +65,21 @@ pub fn regex_escape_len(input: &[u8]) -> usize {
38
65
  /// written.
39
66
  pub fn regex_escape_write(input: &[u8], out: &mut [u8]) -> usize {
40
67
  let mut pos = 0usize;
41
- for &c in input {
42
- if REGEX_META.contains(&c) {
68
+ let mut run_start = 0usize;
69
+ for (i, &c) in input.iter().enumerate() {
70
+ if is_meta(c) {
71
+ let run = &input[run_start..i];
72
+ out[pos..pos + run.len()].copy_from_slice(run);
73
+ pos += run.len();
43
74
  out[pos] = b'\\';
44
- pos += 1;
75
+ out[pos + 1] = c;
76
+ pos += 2;
77
+ run_start = i + 1;
45
78
  }
46
- out[pos] = c;
47
- pos += 1;
48
79
  }
49
- pos
80
+ let tail = &input[run_start..];
81
+ out[pos..pos + tail.len()].copy_from_slice(tail);
82
+ pos + tail.len()
50
83
  }
51
84
 
52
85
  /// Escape JS RegExp metacharacters in a string → a pattern that matches the
@@ -127,4 +160,73 @@ mod tests {
127
160
  let escaped = String::from_utf8(out).expect("utf8");
128
161
  assert_eq!(escaped, r"a\.c");
129
162
  }
163
+
164
+ /// Reference escape (the previous per-byte `REGEX_META.contains` loop).
165
+ fn ref_escape(input: &[u8]) -> Vec<u8> {
166
+ const META: &[u8] = b"\\.*+?^${}()|[]";
167
+ let mut out = Vec::new();
168
+ for &c in input {
169
+ if META.contains(&c) {
170
+ out.push(b'\\');
171
+ }
172
+ out.push(c);
173
+ }
174
+ out
175
+ }
176
+
177
+ #[test]
178
+ fn regex_escape_matches_reference_for_all_lengths() {
179
+ // Every length 0..=96 plus larger payloads: the LUT + run-copy core
180
+ // (bulk memcpy between metachars) must be byte-identical to the naive
181
+ // per-byte reference. Lengths 15/16/17… straddle no SIMD chunk here
182
+ // (the transform is scalar), but the sweep locks positional behavior:
183
+ // a run-copy off-by-one (dropping or duplicating a safe byte) surfaces.
184
+ let mut lens: Vec<usize> = (0..=96).collect();
185
+ lens.extend([128, 512, 16 * 1024, 256 * 1024]);
186
+ for n in lens {
187
+ let input: Vec<u8> = (0..n).map(|i| (i.wrapping_mul(31) + 5) as u8).collect();
188
+ let expect = ref_escape(&input);
189
+ // allocating path
190
+ let mut out = Vec::new();
191
+ regex_escape_into(&input, &mut out);
192
+ assert_eq!(out, expect, "len {n}");
193
+ // len formula
194
+ assert_eq!(regex_escape_len(&input), expect.len(), "len {n}");
195
+ // write path (exact-size buffer, like the C-ABI caller)
196
+ let mut buf = vec![0u8; expect.len()];
197
+ let written = regex_escape_write(&input, &mut buf);
198
+ assert_eq!(written, expect.len(), "len {n}");
199
+ assert_eq!(&buf[..written], &expect[..], "len {n}");
200
+ }
201
+ }
202
+
203
+ #[test]
204
+ fn regex_escape_large_payload_edges() {
205
+ // Three 256 KiB extremes: every byte a metachar (no bulk runs — the
206
+ // worst case for the run-copy core), no metachars at all (pure bulk
207
+ // copy), and a dense alternating pattern. All must match the reference
208
+ // and the exact-length contract.
209
+ let all_meta: Vec<u8> = (0..256 * 1024)
210
+ .map(|i| b"\\.*+?^${}()|[]"[i % 14])
211
+ .collect();
212
+ let none: Vec<u8> = (0..256 * 1024).map(|i| b"abcXYZ0123_-/"[i % 13]).collect();
213
+ let dense: Vec<u8> = (0..256 * 1024)
214
+ .map(|i| if i % 2 == 0 { b'.' } else { b'a' })
215
+ .collect();
216
+ for input in [all_meta, none, dense] {
217
+ let expect = ref_escape(&input);
218
+ let mut out = Vec::new();
219
+ regex_escape_into(&input, &mut out);
220
+ assert_eq!(out, expect);
221
+ assert_eq!(regex_escape_len(&input), expect.len());
222
+ let mut buf = vec![0u8; expect.len()];
223
+ let written = regex_escape_write(&input, &mut buf);
224
+ assert_eq!(written, expect.len());
225
+ assert_eq!(&buf[..written], &expect[..]);
226
+ }
227
+ // All-metachar bound: escaped length is exactly 2x (every byte gets a
228
+ // backslash); all-safe bound: escaped length equals input length.
229
+ assert_eq!(regex_escape_len(&vec![b'.'; 1024]), 2048);
230
+ assert_eq!(regex_escape_len(&vec![b'a'; 1024]), 1024);
231
+ }
130
232
  }
@@ -317,6 +317,40 @@ mod tests {
317
317
  assert!(!validate_uuid_bytes(b"550e8400e29b41d4a716446655440000")); // no dashes
318
318
  }
319
319
 
320
+ /// Byte-for-byte pins the accept set: replacing ANY single hex position
321
+ /// (0..7, 9..12, 15..17, 20..22, 24..35) with a non-hex digit must reject,
322
+ /// and every one of the 32 hex positions must accept uppercase + lowercase
323
+ /// digits. Guards against a future rewrite (e.g. SIMD/run-split) silently
324
+ /// dropping or mis-scoping a position — measured perf-neutral, kept as a
325
+ /// spec-lock regression net.
326
+ #[test]
327
+ fn validate_uuid_hex_run_coverage() {
328
+ let dash = |i: usize| matches!(i, 8 | 13 | 18 | 23);
329
+ for pos in 0..36 {
330
+ let valid = b"550e8400-e29b-41d4-a716-446655440000";
331
+ if dash(pos) {
332
+ continue; // dash positions are pinned by their own check
333
+ }
334
+ // every non-dash position is a hex position (version/variant cols
335
+ // included — 14 must stay '4', 19 must stay in 8/9/a/b set)
336
+ if valid[pos].is_ascii_hexdigit() {
337
+ let mut upper = *valid;
338
+ upper[pos] = valid[pos].to_ascii_uppercase();
339
+ assert!(validate_uuid_bytes(&upper), "uppercase at {pos}");
340
+ }
341
+ let mut bad = *valid;
342
+ bad[pos] = b'g'; // 'g' is not a hex digit
343
+ assert!(!validate_uuid_bytes(&bad), "g at {pos}");
344
+ }
345
+ // and the version / variant columns still reject wrong values
346
+ let mut v = *b"550e8400-e29b-41d4-a716-446655440000";
347
+ v[14] = b'5';
348
+ assert!(!validate_uuid_bytes(&v));
349
+ v = *b"550e8400-e29b-41d4-a716-446655440000";
350
+ v[19] = b'c';
351
+ assert!(!validate_uuid_bytes(&v));
352
+ }
353
+
320
354
  #[test]
321
355
  fn validate_ipv4_cases() {
322
356
  assert!(validate_ipv4_bytes(b"192.168.0.1"));