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