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 +11 -1
- package/Cargo.toml +17 -3
- package/castrum.darwin-arm64.node +0 -0
- package/castrum.darwin-x64.node +0 -0
- package/castrum.linux-arm64-gnu.node +0 -0
- package/castrum.linux-arm64-musl.node +0 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/castrum.linux-x64-musl.node +0 -0
- package/castrum.linux-x64-v3-gnu.node +0 -0
- package/castrum.win32-x64-msvc.node +0 -0
- package/docs/bun-builtins-decision-matrix.md +17 -7
- package/package.json +1 -1
- package/rust/crypto/base64.rs +472 -57
- package/rust/crypto/hashing.rs +68 -0
- package/rust/payload/sse.rs +78 -5
- package/rust/util/text.rs +112 -10
- package/rust/util/validation.rs +34 -0
package/Cargo.lock
CHANGED
|
@@ -233,7 +233,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|
|
233
233
|
|
|
234
234
|
[[package]]
|
|
235
235
|
name = "castrum"
|
|
236
|
-
version = "0.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.
|
|
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
|
|
49
|
-
# feature (part of `default`)
|
|
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
|
package/castrum.darwin-x64.node
CHANGED
|
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** |
|
|
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` (~
|
|
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` |
|
|
78
|
-
| `base64Encode` / `base64UrlEncode` |
|
|
79
|
-
| `hexEncode` | Prefer
|
|
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;
|
|
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),
|
|
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.
|
|
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",
|
package/rust/crypto/base64.rs
CHANGED
|
@@ -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).
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
.decode(text)
|
|
91
|
-
.ok()
|
|
124
|
+
engine(true, false).decode(text).ok()
|
|
92
125
|
}
|
|
93
126
|
|
|
94
127
|
// ── hex ──────────────────────────────────────────────────────────
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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 =
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
173
|
-
///
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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:
|
|
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!";
|
package/rust/crypto/hashing.rs
CHANGED
|
@@ -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
|
}
|
package/rust/payload/sse.rs
CHANGED
|
@@ -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
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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(
|
|
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
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/rust/util/validation.rs
CHANGED
|
@@ -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"));
|