castrum 0.9.3 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2259 -0
- package/Cargo.toml +138 -0
- package/README.md +6 -0
- package/build.rs +5 -0
- package/castrum.linux-x64-gnu.node +0 -0
- package/dist/index.d.ts +1348 -1295
- package/dist/index.js +4307 -3776
- package/docs/API.md +1 -1
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CASE_STUDY.md +2 -2
- package/docs/ENVIRONMENT.md +16 -0
- package/docs/FFI_BUN_GUIDE.md +89 -6
- package/docs/REPO_MAP.md +2 -2
- package/index.ts +19 -25
- package/package.json +9 -1
- package/rust/crypto/aead.rs +378 -0
- package/rust/crypto/argon2.rs +265 -0
- package/rust/crypto/base64.rs +420 -0
- package/rust/crypto/bcrypt.rs +89 -0
- package/rust/crypto/cookie_sign.rs +169 -0
- package/rust/crypto/csrf.rs +106 -0
- package/rust/crypto/ed25519.rs +215 -0
- package/rust/crypto/hashing.rs +140 -0
- package/rust/crypto/hmac_sha256.rs +88 -0
- package/rust/crypto/jwt/api.rs +286 -0
- package/rust/crypto/jwt/mod.rs +22 -0
- package/rust/crypto/jwt/tests.rs +312 -0
- package/rust/crypto/jwt/token.rs +360 -0
- package/rust/crypto/mod.rs +27 -0
- package/rust/crypto/pbkdf2.rs +96 -0
- package/rust/crypto/random_token.rs +85 -0
- package/rust/crypto/session.rs +222 -0
- package/rust/ffi/crypto.rs +708 -0
- package/rust/ffi/hashing.rs +285 -0
- package/rust/ffi/http.rs +752 -0
- package/rust/ffi/ingress.rs +303 -0
- package/rust/ffi/json.rs +517 -0
- package/rust/ffi/jwt.rs +419 -0
- package/rust/ffi/metrics.rs +373 -0
- package/rust/ffi/mod.rs +68 -0
- package/rust/ffi/payload.rs +275 -0
- package/rust/ffi/probe.rs +60 -0
- package/rust/ffi/rate_limit.rs +77 -0
- package/rust/ffi/route.rs +85 -0
- package/rust/ffi/tests.rs +2333 -0
- package/rust/ffi/util.rs +152 -0
- package/rust/ffi/validators.rs +123 -0
- package/rust/http/accept.rs +606 -0
- package/rust/http/cookie_parser.rs +140 -0
- package/rust/http/etag.rs +289 -0
- package/rust/http/form.rs +132 -0
- package/rust/http/headers.rs +267 -0
- package/rust/http/http_date.rs +286 -0
- package/rust/http/http_parser.rs +252 -0
- package/rust/http/media_type.rs +355 -0
- package/rust/http/method.rs +200 -0
- package/rust/http/mime_lookup.rs +130 -0
- package/rust/http/mod.rs +32 -0
- package/rust/http/multipart.rs +685 -0
- package/rust/http/query_parser.rs +180 -0
- package/rust/http/url_codec.rs +296 -0
- package/rust/http/url_join.rs +324 -0
- package/rust/ingress/api.rs +355 -0
- package/rust/ingress/cors.rs +403 -0
- package/rust/ingress/ingress_constants.rs +209 -0
- package/rust/ingress/ip_trust.rs +312 -0
- package/rust/ingress/mod.rs +48 -0
- package/rust/ingress/native_route.rs +779 -0
- package/rust/ingress/options.rs +176 -0
- package/rust/ingress/output.rs +406 -0
- package/rust/ingress/packed.rs +191 -0
- package/rust/ingress/pipeline.rs +534 -0
- package/rust/ingress/proxy.rs +138 -0
- package/rust/ingress/rate_limit.rs +538 -0
- package/rust/ingress/terminal.rs +114 -0
- package/rust/ingress/tests.rs +566 -0
- package/rust/ingress/time.rs +70 -0
- package/rust/json/fast_schema/capture.rs +336 -0
- package/rust/json/fast_schema/compile.rs +556 -0
- package/rust/json/fast_schema/cursor.rs +368 -0
- package/rust/json/fast_schema/email.rs +282 -0
- package/rust/json/fast_schema/errors.rs +247 -0
- package/rust/json/fast_schema/mod.rs +57 -0
- package/rust/json/fast_schema/tests.rs +1143 -0
- package/rust/json/fast_schema/types.rs +140 -0
- package/rust/json/fast_schema/validate.rs +931 -0
- package/rust/json/json_ops.rs +121 -0
- package/rust/json/json_schema.rs +837 -0
- package/rust/json/json_ser.rs +881 -0
- package/rust/json/mod.rs +19 -0
- package/rust/json/napi_marshal.rs +318 -0
- package/rust/json/patch/api.rs +60 -0
- package/rust/json/patch/engine.rs +153 -0
- package/rust/json/patch/mod.rs +47 -0
- package/rust/json/patch/ops.rs +270 -0
- package/rust/json/patch/pointer.rs +49 -0
- package/rust/json/patch/tests.rs +499 -0
- package/rust/lib.rs +72 -0
- package/rust/metrics/api.rs +148 -0
- package/rust/metrics/mod.rs +21 -0
- package/rust/metrics/registry.rs +961 -0
- package/rust/panic_safety.rs +40 -0
- package/rust/payload/compress.rs +512 -0
- package/rust/payload/mod.rs +13 -0
- package/rust/payload/sse.rs +214 -0
- package/rust/payload/template.rs +253 -0
- package/rust/payload/websocket.rs +69 -0
- package/rust/payload/ws_frames.rs +354 -0
- package/rust/proptest_suite.rs +139 -0
- package/rust/selection.rs +95 -0
- package/rust/src/bin/native-bench.rs +495 -0
- package/rust/test_support.rs +71 -0
- package/rust/util/batch/api.rs +266 -0
- package/rust/util/batch/core.rs +473 -0
- package/rust/util/batch/mod.rs +23 -0
- package/rust/util/batch/tests.rs +445 -0
- package/rust/util/batch_core.rs +184 -0
- package/rust/util/bytes.rs +407 -0
- package/rust/util/mod.rs +34 -0
- package/rust/util/packed.rs +473 -0
- package/rust/util/text.rs +130 -0
- package/rust/util/threadpool.rs +128 -0
- package/rust/util/validation.rs +419 -0
- package/rust-toolchain.toml +17 -0
- package/scripts/postinstall.mjs +199 -0
- package/src/ingress/constants.ts +1 -1
- package/src/ingress/context.ts +2 -2
- package/src/ingress/decode/baked-result.ts +13 -13
- package/src/ingress/decode/fast-result.ts +17 -17
- package/src/ingress/errors.ts +7 -7
- package/src/ingress/headers/baked-templates.ts +1 -1
- package/src/ingress/headers/fast-templates.ts +3 -3
- package/src/ingress/index.ts +1 -1
- package/src/ingress/native-route.ts +9 -5
- package/src/ingress/options.ts +1 -1
- package/src/ingress/packing/gather-raw-headers.ts +1 -1
- package/src/ingress/packing/route-wire.ts +1 -5
- package/src/ingress/response/error-bodies.ts +5 -5
- package/src/ingress/response/terminal.ts +1 -1
- package/src/ingress/routes/echo.ts +4 -4
- package/src/ingress/routes/fallback.ts +2 -2
- package/src/ingress/routes/head.ts +2 -2
- package/src/ingress/routes/native.ts +2 -1
- package/src/ingress/routes/options.ts +1 -1
- package/src/ingress/routes/read.ts +2 -2
- package/src/ingress/server.ts +10 -6
- package/src/ingress/status.ts +7 -7
- package/src/integration/batch.ts +1 -1
- package/src/integration/index.ts +9 -9
- package/src/integration/pipeline.ts +6 -6
- package/src/integration/websocket.ts +2 -2
- package/src/loader/index.ts +1 -5
- package/src/loader/ops.ts +1 -1
- package/src/native/ffi/build/codecs.ts +104 -8
- package/src/native/ffi/build/compress.ts +14 -10
- package/src/native/ffi/build/instances.ts +40 -3
- package/src/native/ffi/build/metrics.ts +224 -0
- package/src/native/ffi/build/parse.ts +59 -23
- package/src/native/ffi/build/util.ts +14 -1
- package/src/native/ffi/build.ts +2 -0
- package/src/native/ffi/selftest.ts +10 -3
- package/src/native/ffi/types.ts +111 -9
- package/src/native/ffi.ts +87 -1
- package/src/native/index.ts +1 -1
- package/src/native/loader.ts +2 -2
- package/src/native/types/instances.ts +34 -0
- package/src/native/types.ts +11 -0
- package/src/runtime/codec.ts +53 -9
- package/src/runtime/types.ts +7 -0
- package/src/rust-ffi/batch/build.ts +16 -9
- package/src/rust-ffi/index.ts +7 -6
- package/src/rust-ffi/options.ts +1 -3
- package/src/rust-ffi/proven.ts +3 -3
- package/src/rust-ffi/scalar/crypto.ts +1 -1
- package/src/rust-ffi/scalar/factories.ts +73 -0
- package/src/rust-ffi/scalar/hashing.ts +1 -1
- package/src/rust-ffi/scalar/http.ts +17 -11
- package/src/rust-ffi/scalar/index.ts +2 -0
- package/src/rust-ffi/scalar/interface.ts +20 -0
- package/src/rust-ffi/scalar/payload.ts +11 -3
- package/src/rust-ffi/scalar/validation.ts +121 -0
- package/src/selection.ts +1 -1
- package/src/shared/bytes.ts +5 -4
- package/src/shared/codec.ts +18 -3
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// rust/json/fast_schema/cursor.rs — Byte-level JSON cursor + string/unicode helpers.
|
|
2
|
+
//
|
|
3
|
+
// A `Cursor` walks raw JSON bytes with no allocations. It is used by both the
|
|
4
|
+
// validator (validate.rs) and the structural `skip_value` fast path. The
|
|
5
|
+
// string helpers decode escaped string bodies (only needed for the rare
|
|
6
|
+
// escaped-key path and for counting Unicode scalar values).
|
|
7
|
+
|
|
8
|
+
use super::errors::MAX_DEPTH;
|
|
9
|
+
|
|
10
|
+
/// A cursor over raw JSON bytes.
|
|
11
|
+
pub(crate) struct Cursor<'a> {
|
|
12
|
+
pub(crate) data: &'a [u8],
|
|
13
|
+
pub(crate) pos: usize,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
impl<'a> Cursor<'a> {
|
|
17
|
+
#[inline]
|
|
18
|
+
pub(crate) fn new(data: &'a [u8]) -> Self {
|
|
19
|
+
Self { data, pos: 0 }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
#[inline]
|
|
23
|
+
pub(crate) fn skip_ws(&mut self) {
|
|
24
|
+
while let Some(&b) = self.data.get(self.pos) {
|
|
25
|
+
match b {
|
|
26
|
+
b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1,
|
|
27
|
+
_ => break,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[inline]
|
|
33
|
+
pub(crate) fn peek(&self) -> Option<u8> {
|
|
34
|
+
self.data.get(self.pos).copied()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
#[inline]
|
|
38
|
+
pub(crate) fn eat(&mut self, b: u8) -> bool {
|
|
39
|
+
if self.peek() == Some(b) {
|
|
40
|
+
self.pos += 1;
|
|
41
|
+
true
|
|
42
|
+
} else {
|
|
43
|
+
false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[inline]
|
|
48
|
+
pub(crate) fn eat_token(&mut self, tok: &[u8]) -> bool {
|
|
49
|
+
if self.data.get(self.pos..self.pos + tok.len()) == Some(tok) {
|
|
50
|
+
self.pos += tok.len();
|
|
51
|
+
true
|
|
52
|
+
} else {
|
|
53
|
+
false
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Parse a JSON string token, returning the raw inner slice (between the
|
|
58
|
+
/// quotes). Escapes are validated as escape characters and skipped, but
|
|
59
|
+
/// not decoded. Returns `None` on malformed strings.
|
|
60
|
+
///
|
|
61
|
+
/// RFC-8259 strictness: raw control bytes (`< 0x20`) are invalid inside a
|
|
62
|
+
/// JSON string (they must be escaped), and sonic's `json_valid_bytes`
|
|
63
|
+
/// rejects them. This is the ONE strictness gap the fast path closes so a
|
|
64
|
+
/// fast-path schema can replace the sonic gate — everything else sonic
|
|
65
|
+
/// rejects (leading zeros, trailing commas, trailing garbage, truncated
|
|
66
|
+
/// escapes, missing structure) the structural walk already rejects, and
|
|
67
|
+
/// sonic is lenient on the same things the fast path is (bad `\uXXXX` hex,
|
|
68
|
+
/// lone surrogates, invalid UTF-8, `1e999` overflow).
|
|
69
|
+
pub(crate) fn raw_string(&mut self) -> Option<&'a [u8]> {
|
|
70
|
+
if !self.eat(b'"') {
|
|
71
|
+
return None;
|
|
72
|
+
}
|
|
73
|
+
let start = self.pos;
|
|
74
|
+
let mut i = self.pos;
|
|
75
|
+
while i < self.data.len() {
|
|
76
|
+
// SIMD: find the next `"` or `\` in bulk. A string body is usually
|
|
77
|
+
// long runs of plain bytes, so memchr2 is much faster than the old
|
|
78
|
+
// byte-at-a-time loop over every key and string value. `?` returns
|
|
79
|
+
// None for an unterminated string (no closing quote).
|
|
80
|
+
let rel = memchr::memchr2(b'"', b'\\', &self.data[i..])?;
|
|
81
|
+
// Reject raw control bytes (< 0x20) in the plain run — LLVM
|
|
82
|
+
// auto-vectorizes this comparison, and control bytes are rare in
|
|
83
|
+
// real JSON so the branch is almost never taken.
|
|
84
|
+
if self.data[i..i + rel].iter().any(|&b| b < 0x20) {
|
|
85
|
+
return None;
|
|
86
|
+
}
|
|
87
|
+
i += rel;
|
|
88
|
+
if self.data[i] == b'"' {
|
|
89
|
+
self.pos = i + 1;
|
|
90
|
+
return Some(&self.data[start..i]);
|
|
91
|
+
}
|
|
92
|
+
// Escape: validate the escape char and skip it.
|
|
93
|
+
let e = *self.data.get(i + 1)?;
|
|
94
|
+
if !matches!(
|
|
95
|
+
e,
|
|
96
|
+
b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't' | b'u'
|
|
97
|
+
) {
|
|
98
|
+
return None;
|
|
99
|
+
}
|
|
100
|
+
i += 2;
|
|
101
|
+
}
|
|
102
|
+
None
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// Parse a JSON number token; returns `(value, integer_syntax)`.
|
|
106
|
+
pub(crate) fn number(&mut self) -> Option<(f64, bool)> {
|
|
107
|
+
let start = self.pos;
|
|
108
|
+
let _ = self.eat(b'-');
|
|
109
|
+
match self.peek() {
|
|
110
|
+
Some(b'0') => self.pos += 1,
|
|
111
|
+
Some(b'1'..=b'9') => {
|
|
112
|
+
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
113
|
+
self.pos += 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
_ => return None,
|
|
117
|
+
}
|
|
118
|
+
let mut is_int = true;
|
|
119
|
+
if self.peek() == Some(b'.') {
|
|
120
|
+
is_int = false;
|
|
121
|
+
self.pos += 1;
|
|
122
|
+
if !matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
123
|
+
return None;
|
|
124
|
+
}
|
|
125
|
+
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
126
|
+
self.pos += 1;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if matches!(self.peek(), Some(b'e') | Some(b'E')) {
|
|
130
|
+
is_int = false;
|
|
131
|
+
self.pos += 1;
|
|
132
|
+
if matches!(self.peek(), Some(b'+') | Some(b'-')) {
|
|
133
|
+
self.pos += 1;
|
|
134
|
+
}
|
|
135
|
+
if !matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
136
|
+
return None;
|
|
137
|
+
}
|
|
138
|
+
while matches!(self.peek(), Some(b'0'..=b'9')) {
|
|
139
|
+
self.pos += 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
let tok = &self.data[start..self.pos];
|
|
143
|
+
let n: f64 = std::str::from_utf8(tok).ok()?.parse().ok()?;
|
|
144
|
+
Some((n, is_int))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// Skip one complete JSON value (for `true`/`{}`/`additionalProperties: true`).
|
|
148
|
+
///
|
|
149
|
+
/// `depth` is the current nesting depth (bounded by `MAX_DEPTH`) so hostile
|
|
150
|
+
/// deeply-nested JSON cannot exhaust the native stack through this path
|
|
151
|
+
/// either (see `Ctx::enter_depth`).
|
|
152
|
+
pub(crate) fn skip_value(&mut self, depth: u32) -> bool {
|
|
153
|
+
if depth > MAX_DEPTH {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
self.skip_ws();
|
|
157
|
+
let Some(b) = self.peek() else {
|
|
158
|
+
return false;
|
|
159
|
+
};
|
|
160
|
+
match b {
|
|
161
|
+
b'{' => {
|
|
162
|
+
self.pos += 1;
|
|
163
|
+
self.skip_ws();
|
|
164
|
+
if self.eat(b'}') {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
loop {
|
|
168
|
+
if self.raw_string().is_none() {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
self.skip_ws();
|
|
172
|
+
if !self.eat(b':') {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if !self.skip_value(depth + 1) {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
self.skip_ws();
|
|
179
|
+
if self.eat(b'}') {
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
if !self.eat(b',') {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
self.skip_ws();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
b'[' => {
|
|
189
|
+
self.pos += 1;
|
|
190
|
+
self.skip_ws();
|
|
191
|
+
if self.eat(b']') {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
loop {
|
|
195
|
+
if !self.skip_value(depth + 1) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
self.skip_ws();
|
|
199
|
+
if self.eat(b']') {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if !self.eat(b',') {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
self.skip_ws();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
b'"' => self.raw_string().is_some(),
|
|
209
|
+
b't' => self.eat_token(b"true"),
|
|
210
|
+
b'f' => self.eat_token(b"false"),
|
|
211
|
+
b'n' => self.eat_token(b"null"),
|
|
212
|
+
b'-' | b'0'..=b'9' => self.number().is_some(),
|
|
213
|
+
_ => false,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// True when every byte is ASCII (< 0x80), checked word-at-a-time.
|
|
219
|
+
#[inline]
|
|
220
|
+
fn is_ascii(bytes: &[u8]) -> bool {
|
|
221
|
+
let (chunks, remainder) = bytes.as_chunks::<8>();
|
|
222
|
+
for word in chunks {
|
|
223
|
+
let w = u64::from_le_bytes(*word);
|
|
224
|
+
if (w & 0x8080_8080_8080_8080) != 0 {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
remainder.iter().all(|&b| b < 0x80)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// Count Unicode scalar values (JSON Schema string length) in a raw JSON
|
|
232
|
+
/// string body, handling escapes including surrogate pairs.
|
|
233
|
+
pub(crate) fn count_chars(inner: &[u8]) -> usize {
|
|
234
|
+
// Fast path: no escape sequences and pure ASCII -> one scalar per byte.
|
|
235
|
+
// `contains` (memchr) and `is_ascii` (word-at-a-time) are both SIMD, so
|
|
236
|
+
// the common ASCII name/value case is O(1)-ish instead of byte-at-a-time.
|
|
237
|
+
if !inner.contains(&b'\\') && is_ascii(inner) {
|
|
238
|
+
return inner.len();
|
|
239
|
+
}
|
|
240
|
+
let mut chars = 0usize;
|
|
241
|
+
let mut i = 0usize;
|
|
242
|
+
while i < inner.len() {
|
|
243
|
+
let b = inner[i];
|
|
244
|
+
if b == b'\\' {
|
|
245
|
+
if i + 1 >= inner.len() {
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if inner[i + 1] == b'u' {
|
|
249
|
+
chars += 1;
|
|
250
|
+
i += 6;
|
|
251
|
+
// High surrogate followed by a low surrogate is ONE scalar value.
|
|
252
|
+
if i + 6 <= inner.len()
|
|
253
|
+
&& inner.get(i..i + 2) == Some(b"\\u")
|
|
254
|
+
&& hex4(inner.get(i - 4..i)).is_some_and(|hi| (0xD800..=0xDBFF).contains(&hi))
|
|
255
|
+
&& hex4(inner.get(i + 2..i + 6))
|
|
256
|
+
.is_some_and(|lo| (0xDC00..=0xDFFF).contains(&lo))
|
|
257
|
+
{
|
|
258
|
+
i += 6;
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
chars += 1;
|
|
262
|
+
i += 2;
|
|
263
|
+
}
|
|
264
|
+
} else if b < 0x80 {
|
|
265
|
+
chars += 1;
|
|
266
|
+
i += 1;
|
|
267
|
+
} else {
|
|
268
|
+
chars += 1;
|
|
269
|
+
i += match b {
|
|
270
|
+
0xC0..=0xDF => 2,
|
|
271
|
+
0xE0..=0xEF => 3,
|
|
272
|
+
0xF0..=0xF7 => 4,
|
|
273
|
+
_ => 1,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
chars
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fn hex4(hex: Option<&[u8]>) -> Option<u16> {
|
|
281
|
+
let hex = hex?;
|
|
282
|
+
if hex.len() != 4 {
|
|
283
|
+
return None;
|
|
284
|
+
}
|
|
285
|
+
let mut v = 0u16;
|
|
286
|
+
for &c in hex {
|
|
287
|
+
let d = match c {
|
|
288
|
+
b'0'..=b'9' => c - b'0',
|
|
289
|
+
b'a'..=b'f' => c - b'a' + 10,
|
|
290
|
+
b'A'..=b'F' => c - b'A' + 10,
|
|
291
|
+
_ => return None,
|
|
292
|
+
};
|
|
293
|
+
v = v * 16 + d as u16;
|
|
294
|
+
}
|
|
295
|
+
Some(v)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fn push_cp(out: &mut Vec<u8>, cp: u32) {
|
|
299
|
+
if cp < 0x80 {
|
|
300
|
+
out.push(cp as u8);
|
|
301
|
+
} else if cp < 0x800 {
|
|
302
|
+
out.push(0xC0 | (cp >> 6) as u8);
|
|
303
|
+
out.push(0x80 | (cp & 0x3F) as u8);
|
|
304
|
+
} else if cp < 0x10000 {
|
|
305
|
+
out.push(0xE0 | (cp >> 12) as u8);
|
|
306
|
+
out.push(0x80 | ((cp >> 6) & 0x3F) as u8);
|
|
307
|
+
out.push(0x80 | (cp & 0x3F) as u8);
|
|
308
|
+
} else {
|
|
309
|
+
out.push(0xF0 | (cp >> 18) as u8);
|
|
310
|
+
out.push(0x80 | ((cp >> 12) & 0x3F) as u8);
|
|
311
|
+
out.push(0x80 | ((cp >> 6) & 0x3F) as u8);
|
|
312
|
+
out.push(0x80 | (cp & 0x3F) as u8);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/// Decode a raw JSON string body into literal bytes. Only used for object keys
|
|
317
|
+
/// that contain escapes (rare); the common no-escape path is a zero-alloc
|
|
318
|
+
/// slice lookup.
|
|
319
|
+
pub(crate) fn decode_string(inner: &[u8]) -> Vec<u8> {
|
|
320
|
+
let mut out = Vec::with_capacity(inner.len());
|
|
321
|
+
let mut i = 0usize;
|
|
322
|
+
while i < inner.len() {
|
|
323
|
+
let b = inner[i];
|
|
324
|
+
if b == b'\\' && i + 1 < inner.len() {
|
|
325
|
+
let e = inner[i + 1];
|
|
326
|
+
i += 2;
|
|
327
|
+
match e {
|
|
328
|
+
b'"' => out.push(b'"'),
|
|
329
|
+
b'\\' => out.push(b'\\'),
|
|
330
|
+
b'/' => out.push(b'/'),
|
|
331
|
+
b'b' => out.push(0x08),
|
|
332
|
+
b'f' => out.push(0x0C),
|
|
333
|
+
b'n' => out.push(b'\n'),
|
|
334
|
+
b'r' => out.push(b'\r'),
|
|
335
|
+
b't' => out.push(b'\t'),
|
|
336
|
+
b'u' => {
|
|
337
|
+
if let Some(h) = hex4(inner.get(i..i + 4)) {
|
|
338
|
+
i += 4;
|
|
339
|
+
if (0xD800..=0xDBFF).contains(&h) {
|
|
340
|
+
let low = inner
|
|
341
|
+
.get(i..i + 2)
|
|
342
|
+
.filter(|s| **s == *b"\\u")
|
|
343
|
+
.and_then(|_| hex4(inner.get(i + 2..i + 6)));
|
|
344
|
+
if let Some(l) = low.filter(|l| (0xDC00..=0xDFFF).contains(l)) {
|
|
345
|
+
i += 6;
|
|
346
|
+
let cp =
|
|
347
|
+
0x10000 + (((h as u32) - 0xD800) << 10) + ((l as u32) - 0xDC00);
|
|
348
|
+
push_cp(&mut out, cp);
|
|
349
|
+
} else {
|
|
350
|
+
// Lone high surrogate: encode as replacement char.
|
|
351
|
+
push_cp(&mut out, 0xFFFD);
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
push_cp(&mut out, h as u32);
|
|
355
|
+
}
|
|
356
|
+
} else {
|
|
357
|
+
out.push(b'u');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
_ => out.push(b),
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
out.push(b);
|
|
364
|
+
i += 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
out
|
|
368
|
+
}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// rust/json/fast_schema/email.rs — Zero-DOM `format: "email"` validator.
|
|
2
|
+
//
|
|
3
|
+
// Byte-parity replica of jsonschema 0.48.5's `keywords::format::is_valid_email`
|
|
4
|
+
// (the default, non-IDN path used by `format: "email"`):
|
|
5
|
+
//
|
|
6
|
+
// 1. structural local-part parse via the SAME `email_address` crate
|
|
7
|
+
// (`EmailAddress::from_str` — jsonschema's `parse_email(email, None)`),
|
|
8
|
+
// 2. then the domain is either an `[IPv6:]`/`[IPv4]` literal, or an RFC 1034
|
|
9
|
+
// hostname with RFC 5891 A-label (punycode) + RFC 5892 PVALID/contextual
|
|
10
|
+
// unicode-label rules.
|
|
11
|
+
//
|
|
12
|
+
// `idna` and `unicode-general-category` are the same crates/versions the
|
|
13
|
+
// jsonschema crate pulls in, so the two can only drift if the pinned
|
|
14
|
+
// `jsonschema` dep is upgraded — in that case re-verify parity here (see
|
|
15
|
+
// `tests.rs` for the parity corpus + property test).
|
|
16
|
+
//
|
|
17
|
+
// NOTE: only `format: "email"` (non-IDN) is supported. `format: "idn-email"`
|
|
18
|
+
// and every other format value keep the DOM fallback (compile returns Err).
|
|
19
|
+
|
|
20
|
+
use std::net::{Ipv4Addr, Ipv6Addr};
|
|
21
|
+
use std::str::FromStr;
|
|
22
|
+
|
|
23
|
+
use email_address::EmailAddress;
|
|
24
|
+
use idna::punycode::decode_to_string;
|
|
25
|
+
use unicode_general_category::{get_general_category, GeneralCategory};
|
|
26
|
+
|
|
27
|
+
/// RFC 1034 hostname chars (`a-zA-Z0-9-`).
|
|
28
|
+
const VALID_HOSTNAME_CHARS: [bool; 256] = {
|
|
29
|
+
let mut table = [false; 256];
|
|
30
|
+
let mut byte: u8 = 0;
|
|
31
|
+
while byte < 255 {
|
|
32
|
+
table[byte as usize] = matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-');
|
|
33
|
+
byte += 1;
|
|
34
|
+
}
|
|
35
|
+
// Handle byte 255 separately to avoid overflow.
|
|
36
|
+
table[255] = matches!(255u8, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-');
|
|
37
|
+
table
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
#[inline]
|
|
41
|
+
fn is_punycode_label(label: &[u8]) -> bool {
|
|
42
|
+
label.len() >= 4 && label[0] == b'x' && label[1] == b'n' && label[2] == b'-' && label[3] == b'-'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[inline]
|
|
46
|
+
fn validate_hostname_label(label: &[u8]) -> bool {
|
|
47
|
+
!label.is_empty() && label.len() <= 63 && label[0] != b'-' && *label.last().unwrap() != b'-'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// RFC 1034 ASCII hostname shape: 1..=253 chars, no trailing dot, each label
|
|
51
|
+
/// non-empty / ≤63 / no leading or trailing hyphen, chars in `a-zA-Z0-9-`.
|
|
52
|
+
fn is_valid_ascii_hostname(hostname: &str) -> bool {
|
|
53
|
+
let hostname_bytes = hostname.as_bytes();
|
|
54
|
+
let len = hostname_bytes.len();
|
|
55
|
+
if len == 0 || len > 253 || hostname_bytes[len - 1] == b'.' {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let mut label_start = 0;
|
|
60
|
+
let mut i = 0;
|
|
61
|
+
while i < len {
|
|
62
|
+
if hostname_bytes[i] == b'.' {
|
|
63
|
+
if !validate_hostname_label(&hostname_bytes[label_start..i]) {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
label_start = i + 1;
|
|
67
|
+
} else if !VALID_HOSTNAME_CHARS[hostname_bytes[i] as usize] {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
i += 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
validate_hostname_label(&hostname_bytes[label_start..])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// RFC 5892 derives the PVALID property primarily from these general categories.
|
|
77
|
+
fn is_idna_pvalid_category(category: GeneralCategory) -> bool {
|
|
78
|
+
matches!(
|
|
79
|
+
category,
|
|
80
|
+
GeneralCategory::UppercaseLetter
|
|
81
|
+
| GeneralCategory::LowercaseLetter
|
|
82
|
+
| GeneralCategory::TitlecaseLetter
|
|
83
|
+
| GeneralCategory::ModifierLetter
|
|
84
|
+
| GeneralCategory::OtherLetter
|
|
85
|
+
| GeneralCategory::NonspacingMark
|
|
86
|
+
| GeneralCategory::SpacingMark
|
|
87
|
+
| GeneralCategory::DecimalNumber
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// RFC 5892 §2 context-aware label rules + PVALID check. Replica of
|
|
92
|
+
/// jsonschema 0.48.5 `validate_unicode_label`.
|
|
93
|
+
fn validate_unicode_label(label: &str) -> bool {
|
|
94
|
+
let mut chars = label.chars().peekable();
|
|
95
|
+
if let Some(&first) = chars.peek() {
|
|
96
|
+
let category = get_general_category(first);
|
|
97
|
+
if matches!(
|
|
98
|
+
category,
|
|
99
|
+
GeneralCategory::SpacingMark
|
|
100
|
+
| GeneralCategory::NonspacingMark
|
|
101
|
+
| GeneralCategory::EnclosingMark
|
|
102
|
+
) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
let mut previous = None;
|
|
107
|
+
let mut has_katakana_middle_dot = false;
|
|
108
|
+
let mut has_hiragana_katakana_han = false;
|
|
109
|
+
let mut has_arabic_indic_digits = false;
|
|
110
|
+
let mut has_extended_arabic_indic_digits = false;
|
|
111
|
+
|
|
112
|
+
while let Some(current) = chars.next() {
|
|
113
|
+
match current {
|
|
114
|
+
// ZERO WIDTH JOINER — allowed only after a virama.
|
|
115
|
+
'\u{200D}'
|
|
116
|
+
if !previous.is_some_and(|prev| {
|
|
117
|
+
matches!(
|
|
118
|
+
prev,
|
|
119
|
+
'\u{094D}'
|
|
120
|
+
| '\u{09CD}'
|
|
121
|
+
| '\u{0A4D}'
|
|
122
|
+
| '\u{0ACD}'
|
|
123
|
+
| '\u{0B4D}'
|
|
124
|
+
| '\u{0BCD}'
|
|
125
|
+
| '\u{0C4D}'
|
|
126
|
+
| '\u{0CCD}'
|
|
127
|
+
| '\u{0D4D}'
|
|
128
|
+
| '\u{0DCA}'
|
|
129
|
+
| '\u{0E3A}'
|
|
130
|
+
| '\u{0F84}'
|
|
131
|
+
| '\u{1039}'
|
|
132
|
+
| '\u{1714}'
|
|
133
|
+
| '\u{1734}'
|
|
134
|
+
| '\u{17D2}'
|
|
135
|
+
| '\u{1A60}'
|
|
136
|
+
| '\u{1B44}'
|
|
137
|
+
| '\u{1BAA}'
|
|
138
|
+
| '\u{1BF2}'
|
|
139
|
+
| '\u{1BF3}'
|
|
140
|
+
| '\u{2D7F}'
|
|
141
|
+
| '\u{A806}'
|
|
142
|
+
| '\u{A8C4}'
|
|
143
|
+
| '\u{A953}'
|
|
144
|
+
| '\u{ABED}'
|
|
145
|
+
| '\u{10A3F}'
|
|
146
|
+
| '\u{11046}'
|
|
147
|
+
| '\u{1107F}'
|
|
148
|
+
| '\u{110B9}'
|
|
149
|
+
| '\u{11133}'
|
|
150
|
+
| '\u{111C0}'
|
|
151
|
+
| '\u{11235}'
|
|
152
|
+
| '\u{112EA}'
|
|
153
|
+
| '\u{1134D}'
|
|
154
|
+
| '\u{11442}'
|
|
155
|
+
| '\u{114C2}'
|
|
156
|
+
| '\u{115BF}'
|
|
157
|
+
| '\u{1163F}'
|
|
158
|
+
| '\u{116B6}'
|
|
159
|
+
| '\u{1172B}'
|
|
160
|
+
| '\u{11839}'
|
|
161
|
+
| '\u{119E0}'
|
|
162
|
+
| '\u{11A34}'
|
|
163
|
+
| '\u{11A47}'
|
|
164
|
+
| '\u{11A99}'
|
|
165
|
+
| '\u{11C3F}'
|
|
166
|
+
| '\u{11D44}'
|
|
167
|
+
| '\u{11D45}'
|
|
168
|
+
| '\u{11D97}'
|
|
169
|
+
)
|
|
170
|
+
}) =>
|
|
171
|
+
{
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
// MIDDLE DOT — must be between 'l' and 'l'.
|
|
175
|
+
'\u{00B7}' if previous != Some('l') || chars.peek() != Some(&'l') => return false,
|
|
176
|
+
// Greek KERAIA — must be followed by a Greek char.
|
|
177
|
+
'\u{0375}'
|
|
178
|
+
if !chars
|
|
179
|
+
.peek()
|
|
180
|
+
.is_some_and(|next| ('\u{0370}'..='\u{03FF}').contains(next)) =>
|
|
181
|
+
{
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
// Hebrew GERESH / GERSHAYIM — must follow a Hebrew char.
|
|
185
|
+
'\u{05F3}' | '\u{05F4}'
|
|
186
|
+
if !previous.is_some_and(|prev| ('\u{0590}'..='\u{05FF}').contains(&prev)) =>
|
|
187
|
+
{
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
// KATAKANA MIDDLE DOT.
|
|
191
|
+
'\u{30FB}' => has_katakana_middle_dot = true,
|
|
192
|
+
// Hiragana, Katakana, or Han.
|
|
193
|
+
'\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}' | '\u{4E00}'..='\u{9FFF}' => {
|
|
194
|
+
has_hiragana_katakana_han = true;
|
|
195
|
+
}
|
|
196
|
+
// ARABIC-INDIC DIGITS.
|
|
197
|
+
'\u{0660}'..='\u{0669}' => has_arabic_indic_digits = true,
|
|
198
|
+
// EXTENDED ARABIC-INDIC DIGITS.
|
|
199
|
+
'\u{06F0}'..='\u{06F9}' => has_extended_arabic_indic_digits = true,
|
|
200
|
+
// DISALLOWED.
|
|
201
|
+
'\u{0640}' | '\u{07FA}' | '\u{302E}' | '\u{302F}' | '\u{3031}' | '\u{3032}'
|
|
202
|
+
| '\u{3033}' | '\u{3034}' | '\u{3035}' | '\u{303B}' => return false,
|
|
203
|
+
// Contextual joiners/punctuation already validated above, plus the
|
|
204
|
+
// RFC 5892 PVALID exceptions whose general category would otherwise
|
|
205
|
+
// be disallowed.
|
|
206
|
+
'\u{200C}' | '\u{200D}' | '\u{00B7}' | '\u{0375}' | '\u{05F3}' | '\u{05F4}'
|
|
207
|
+
| '\u{06FD}' | '\u{06FE}' | '\u{0F0B}' | '\u{3007}' => {}
|
|
208
|
+
// Per RFC 5892 a code point is PVALID only when it is a letter, a
|
|
209
|
+
// combining mark, or a decimal digit; any other decoded code point
|
|
210
|
+
// is disallowed.
|
|
211
|
+
other if !other.is_ascii() && !is_idna_pvalid_category(get_general_category(other)) => {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
_ => {}
|
|
215
|
+
}
|
|
216
|
+
previous = Some(current);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (has_katakana_middle_dot && !has_hiragana_katakana_han)
|
|
220
|
+
|| (has_arabic_indic_digits && has_extended_arabic_indic_digits)
|
|
221
|
+
{
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
true
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/// RFC 1034 hostname + RFC 5891 A-label + RFC 5892 unicode-label rules
|
|
229
|
+
/// (non-IDN path — ASCII hostname shape, punycode A-labels decoded + checked).
|
|
230
|
+
fn is_valid_hostname(hostname: &str) -> bool {
|
|
231
|
+
if !is_valid_ascii_hostname(hostname) {
|
|
232
|
+
return false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for label in hostname.as_bytes().split(|&b| b == b'.') {
|
|
236
|
+
// Per RFC 5891, labels with hyphens in 3rd & 4th positions must be
|
|
237
|
+
// valid A-labels.
|
|
238
|
+
if label.len() >= 4 && label[2] == b'-' && label[3] == b'-' && !is_punycode_label(label) {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if is_punycode_label(label) {
|
|
243
|
+
// SAFETY: `is_valid_ascii_hostname` already checked the label is
|
|
244
|
+
// pure `a-zA-Z0-9-` ASCII, so slicing past the `xn--` prefix is
|
|
245
|
+
// valid UTF-8 (mirrors jsonschema's `.expect`).
|
|
246
|
+
let payload = std::str::from_utf8(&label[4..]).expect("ASCII label already validated");
|
|
247
|
+
let Some(decoded) = decode_to_string(payload) else {
|
|
248
|
+
return false;
|
|
249
|
+
};
|
|
250
|
+
if !validate_unicode_label(&decoded) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
true
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// `[IPv6:...]` / `[IPv4...]` literals, else the hostname rules.
|
|
260
|
+
fn validate_email_domain(domain: &str) -> bool {
|
|
261
|
+
if let Some(domain) = domain.strip_prefix('[').and_then(|d| d.strip_suffix(']')) {
|
|
262
|
+
if let Some(domain) = domain.strip_prefix("IPv6:") {
|
|
263
|
+
domain.parse::<Ipv6Addr>().is_ok()
|
|
264
|
+
} else {
|
|
265
|
+
domain.parse::<Ipv4Addr>().is_ok()
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
is_valid_hostname(domain)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// Validate `input` as an RFC 5321/5322 email — byte-parity with jsonschema
|
|
273
|
+
/// 0.48.5 `format: "email"` (default options, non-ASCII local parts rejected).
|
|
274
|
+
pub fn is_valid_email_format(input: &[u8]) -> bool {
|
|
275
|
+
let Ok(s) = std::str::from_utf8(input) else {
|
|
276
|
+
return false;
|
|
277
|
+
};
|
|
278
|
+
let Ok(parsed) = EmailAddress::from_str(s) else {
|
|
279
|
+
return false;
|
|
280
|
+
};
|
|
281
|
+
validate_email_domain(parsed.domain())
|
|
282
|
+
}
|