castrum 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/Cargo.lock +2259 -0
  2. package/Cargo.toml +138 -0
  3. package/README.md +6 -0
  4. package/build.rs +5 -0
  5. package/castrum.linux-x64-gnu.node +0 -0
  6. package/dist/index.d.ts +1348 -1295
  7. package/dist/index.js +4307 -3776
  8. package/docs/API.md +1 -1
  9. package/docs/ARCHITECTURE.md +3 -2
  10. package/docs/CASE_STUDY.md +2 -2
  11. package/docs/ENVIRONMENT.md +16 -0
  12. package/docs/FFI_BUN_GUIDE.md +89 -6
  13. package/docs/REPO_MAP.md +2 -2
  14. package/index.ts +19 -25
  15. package/package.json +9 -1
  16. package/rust/crypto/aead.rs +378 -0
  17. package/rust/crypto/argon2.rs +265 -0
  18. package/rust/crypto/base64.rs +420 -0
  19. package/rust/crypto/bcrypt.rs +89 -0
  20. package/rust/crypto/cookie_sign.rs +169 -0
  21. package/rust/crypto/csrf.rs +106 -0
  22. package/rust/crypto/ed25519.rs +215 -0
  23. package/rust/crypto/hashing.rs +140 -0
  24. package/rust/crypto/hmac_sha256.rs +88 -0
  25. package/rust/crypto/jwt/api.rs +286 -0
  26. package/rust/crypto/jwt/mod.rs +22 -0
  27. package/rust/crypto/jwt/tests.rs +312 -0
  28. package/rust/crypto/jwt/token.rs +360 -0
  29. package/rust/crypto/mod.rs +27 -0
  30. package/rust/crypto/pbkdf2.rs +96 -0
  31. package/rust/crypto/random_token.rs +85 -0
  32. package/rust/crypto/session.rs +222 -0
  33. package/rust/ffi/crypto.rs +708 -0
  34. package/rust/ffi/hashing.rs +285 -0
  35. package/rust/ffi/http.rs +752 -0
  36. package/rust/ffi/ingress.rs +303 -0
  37. package/rust/ffi/json.rs +517 -0
  38. package/rust/ffi/jwt.rs +419 -0
  39. package/rust/ffi/metrics.rs +373 -0
  40. package/rust/ffi/mod.rs +68 -0
  41. package/rust/ffi/payload.rs +275 -0
  42. package/rust/ffi/probe.rs +60 -0
  43. package/rust/ffi/rate_limit.rs +77 -0
  44. package/rust/ffi/route.rs +85 -0
  45. package/rust/ffi/tests.rs +2333 -0
  46. package/rust/ffi/util.rs +152 -0
  47. package/rust/ffi/validators.rs +123 -0
  48. package/rust/http/accept.rs +606 -0
  49. package/rust/http/cookie_parser.rs +140 -0
  50. package/rust/http/etag.rs +289 -0
  51. package/rust/http/form.rs +132 -0
  52. package/rust/http/headers.rs +267 -0
  53. package/rust/http/http_date.rs +286 -0
  54. package/rust/http/http_parser.rs +252 -0
  55. package/rust/http/media_type.rs +355 -0
  56. package/rust/http/method.rs +200 -0
  57. package/rust/http/mime_lookup.rs +130 -0
  58. package/rust/http/mod.rs +32 -0
  59. package/rust/http/multipart.rs +685 -0
  60. package/rust/http/query_parser.rs +180 -0
  61. package/rust/http/url_codec.rs +296 -0
  62. package/rust/http/url_join.rs +324 -0
  63. package/rust/ingress/api.rs +355 -0
  64. package/rust/ingress/cors.rs +403 -0
  65. package/rust/ingress/ingress_constants.rs +209 -0
  66. package/rust/ingress/ip_trust.rs +312 -0
  67. package/rust/ingress/mod.rs +48 -0
  68. package/rust/ingress/native_route.rs +779 -0
  69. package/rust/ingress/options.rs +176 -0
  70. package/rust/ingress/output.rs +406 -0
  71. package/rust/ingress/packed.rs +191 -0
  72. package/rust/ingress/pipeline.rs +534 -0
  73. package/rust/ingress/proxy.rs +138 -0
  74. package/rust/ingress/rate_limit.rs +538 -0
  75. package/rust/ingress/terminal.rs +114 -0
  76. package/rust/ingress/tests.rs +566 -0
  77. package/rust/ingress/time.rs +70 -0
  78. package/rust/json/fast_schema/capture.rs +336 -0
  79. package/rust/json/fast_schema/compile.rs +556 -0
  80. package/rust/json/fast_schema/cursor.rs +368 -0
  81. package/rust/json/fast_schema/email.rs +282 -0
  82. package/rust/json/fast_schema/errors.rs +247 -0
  83. package/rust/json/fast_schema/mod.rs +57 -0
  84. package/rust/json/fast_schema/tests.rs +1143 -0
  85. package/rust/json/fast_schema/types.rs +140 -0
  86. package/rust/json/fast_schema/validate.rs +931 -0
  87. package/rust/json/json_ops.rs +121 -0
  88. package/rust/json/json_schema.rs +837 -0
  89. package/rust/json/json_ser.rs +881 -0
  90. package/rust/json/mod.rs +19 -0
  91. package/rust/json/napi_marshal.rs +318 -0
  92. package/rust/json/patch/api.rs +60 -0
  93. package/rust/json/patch/engine.rs +153 -0
  94. package/rust/json/patch/mod.rs +47 -0
  95. package/rust/json/patch/ops.rs +270 -0
  96. package/rust/json/patch/pointer.rs +49 -0
  97. package/rust/json/patch/tests.rs +499 -0
  98. package/rust/lib.rs +72 -0
  99. package/rust/metrics/api.rs +148 -0
  100. package/rust/metrics/mod.rs +21 -0
  101. package/rust/metrics/registry.rs +961 -0
  102. package/rust/panic_safety.rs +40 -0
  103. package/rust/payload/compress.rs +512 -0
  104. package/rust/payload/mod.rs +13 -0
  105. package/rust/payload/sse.rs +214 -0
  106. package/rust/payload/template.rs +253 -0
  107. package/rust/payload/websocket.rs +69 -0
  108. package/rust/payload/ws_frames.rs +354 -0
  109. package/rust/proptest_suite.rs +139 -0
  110. package/rust/selection.rs +95 -0
  111. package/rust/src/bin/native-bench.rs +495 -0
  112. package/rust/test_support.rs +71 -0
  113. package/rust/util/batch/api.rs +266 -0
  114. package/rust/util/batch/core.rs +473 -0
  115. package/rust/util/batch/mod.rs +23 -0
  116. package/rust/util/batch/tests.rs +445 -0
  117. package/rust/util/batch_core.rs +184 -0
  118. package/rust/util/bytes.rs +407 -0
  119. package/rust/util/mod.rs +34 -0
  120. package/rust/util/packed.rs +473 -0
  121. package/rust/util/text.rs +130 -0
  122. package/rust/util/threadpool.rs +128 -0
  123. package/rust/util/validation.rs +419 -0
  124. package/rust-toolchain.toml +17 -0
  125. package/scripts/postinstall.mjs +199 -0
  126. package/src/ingress/constants.ts +1 -1
  127. package/src/ingress/context.ts +2 -2
  128. package/src/ingress/decode/baked-result.ts +13 -13
  129. package/src/ingress/decode/fast-result.ts +17 -17
  130. package/src/ingress/errors.ts +7 -7
  131. package/src/ingress/headers/baked-templates.ts +1 -1
  132. package/src/ingress/headers/fast-templates.ts +3 -3
  133. package/src/ingress/index.ts +1 -1
  134. package/src/ingress/native-route.ts +9 -5
  135. package/src/ingress/options.ts +1 -1
  136. package/src/ingress/packing/gather-raw-headers.ts +1 -1
  137. package/src/ingress/packing/route-wire.ts +1 -5
  138. package/src/ingress/response/error-bodies.ts +5 -5
  139. package/src/ingress/response/terminal.ts +1 -1
  140. package/src/ingress/routes/echo.ts +4 -4
  141. package/src/ingress/routes/fallback.ts +2 -2
  142. package/src/ingress/routes/head.ts +2 -2
  143. package/src/ingress/routes/native.ts +2 -1
  144. package/src/ingress/routes/options.ts +1 -1
  145. package/src/ingress/routes/read.ts +2 -2
  146. package/src/ingress/server.ts +10 -6
  147. package/src/ingress/status.ts +7 -7
  148. package/src/integration/batch.ts +1 -1
  149. package/src/integration/index.ts +9 -9
  150. package/src/integration/pipeline.ts +6 -6
  151. package/src/integration/websocket.ts +2 -2
  152. package/src/loader/index.ts +1 -5
  153. package/src/loader/ops.ts +1 -1
  154. package/src/native/ffi/build/codecs.ts +104 -8
  155. package/src/native/ffi/build/compress.ts +14 -10
  156. package/src/native/ffi/build/instances.ts +40 -3
  157. package/src/native/ffi/build/metrics.ts +224 -0
  158. package/src/native/ffi/build/parse.ts +59 -23
  159. package/src/native/ffi/build/util.ts +14 -1
  160. package/src/native/ffi/build.ts +2 -0
  161. package/src/native/ffi/selftest.ts +10 -3
  162. package/src/native/ffi/types.ts +111 -9
  163. package/src/native/ffi.ts +87 -1
  164. package/src/native/index.ts +1 -1
  165. package/src/native/loader.ts +2 -2
  166. package/src/native/types/instances.ts +34 -0
  167. package/src/native/types.ts +11 -0
  168. package/src/runtime/codec.ts +53 -9
  169. package/src/runtime/types.ts +7 -0
  170. package/src/rust-ffi/batch/build.ts +16 -9
  171. package/src/rust-ffi/index.ts +7 -6
  172. package/src/rust-ffi/options.ts +1 -3
  173. package/src/rust-ffi/proven.ts +3 -3
  174. package/src/rust-ffi/scalar/crypto.ts +1 -1
  175. package/src/rust-ffi/scalar/factories.ts +73 -0
  176. package/src/rust-ffi/scalar/hashing.ts +1 -1
  177. package/src/rust-ffi/scalar/http.ts +17 -11
  178. package/src/rust-ffi/scalar/index.ts +2 -0
  179. package/src/rust-ffi/scalar/interface.ts +20 -0
  180. package/src/rust-ffi/scalar/payload.ts +11 -3
  181. package/src/rust-ffi/scalar/validation.ts +121 -0
  182. package/src/selection.ts +1 -1
  183. package/src/shared/bytes.ts +5 -4
  184. package/src/shared/codec.ts +18 -3
@@ -0,0 +1,1143 @@
1
+ // rust/json/fast_schema/tests.rs — Fast-path vs jsonschema-crate parity tests.
2
+ //
3
+ // Every supported construct is checked for exact agreement with the reference
4
+ // `jsonschema` crate (the authoritative DOM path); unsupported keywords must
5
+ // fail to compile so the caller falls back.
6
+
7
+ use super::*;
8
+ use serde_json::{json, Value};
9
+
10
+ fn assert_parity(schema: Value, docs: &[&str]) {
11
+ let fast = compile(&schema).expect("schema must compile on the fast path");
12
+ // The fast path implements draft-07; pin the reference so parity is
13
+ // meaningful (jsonschema 0.48's default draft is 2020-12, whose `items`/
14
+ // `$ref`-sibling/boolean-exclusive semantics differ from draft-07).
15
+ let v = jsonschema::options()
16
+ .with_draft(jsonschema::Draft::Draft7)
17
+ .should_validate_formats(true)
18
+ .build(&schema)
19
+ .expect("schema must compile on reference");
20
+ for &doc in docs {
21
+ let expected = serde_json::from_str::<Value>(doc)
22
+ .map(|value| v.is_valid(&value))
23
+ .unwrap_or(false);
24
+ let got = fast.is_valid_bytes(doc.as_bytes());
25
+ assert_eq!(got, expected, "schema={schema} doc={doc}");
26
+ }
27
+ }
28
+
29
+ fn bench_schema() -> Value {
30
+ json!({
31
+ "type": "object",
32
+ "required": ["id", "name", "active", "score", "tags", "nested"],
33
+ "properties": {
34
+ "id": { "type": "number" },
35
+ "name": { "type": "string", "minLength": 1 },
36
+ "active": { "type": "boolean" },
37
+ "score": { "type": "number" },
38
+ "tags": { "type": "array", "items": { "type": "string" }, "maxItems": 20 },
39
+ "nested": {
40
+ "type": "object",
41
+ "required": ["version", "createdAt"],
42
+ "properties": {
43
+ "version": { "type": "number" },
44
+ "createdAt": { "type": "string" }
45
+ },
46
+ "additionalProperties": false
47
+ }
48
+ },
49
+ "additionalProperties": false
50
+ })
51
+ }
52
+
53
+ #[test]
54
+ fn bench_schema_parity() {
55
+ let schema = bench_schema();
56
+ let docs = [
57
+ // valid
58
+ r#"{"id":1,"name":"user_1","active":true,"score":1.25,"tags":["alpha","beta"],"nested":{"version":1,"createdAt":"2026-01-01T00:00:00Z"}}"#,
59
+ r#"{"id":2,"name":"b","active":false,"score":0,"tags":[],"nested":{"version":2,"createdAt":"x"}}"#,
60
+ // wrong root type
61
+ r#"[]"#,
62
+ r#"42"#,
63
+ r#""hi""#,
64
+ r#"null"#,
65
+ // wrong property types
66
+ r#"{"id":"x","name":"a","active":true,"score":1,"tags":[],"nested":{"version":1,"createdAt":"c"}}"#,
67
+ r#"{"id":1,"name":"a","active":"yes","score":1,"tags":[],"nested":{"version":1,"createdAt":"c"}}"#,
68
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":["a",1],"nested":{"version":1,"createdAt":"c"}}"#,
69
+ // missing required
70
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":[]}"#,
71
+ // extra property (additionalProperties:false)
72
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":[],"extra":1,"nested":{"version":1,"createdAt":"c"}}"#,
73
+ // nested extra property
74
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":[],"nested":{"version":1,"createdAt":"c","z":2}}"#,
75
+ // nested missing required
76
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":[],"nested":{"version":1}}"#,
77
+ // minLength violation + maxItems violation
78
+ r#"{"id":1,"name":"","active":true,"score":1,"tags":[],"nested":{"version":1,"createdAt":"c"}}"#,
79
+ r#"{"id":1,"name":"a","active":true,"score":1,"tags":["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u"],"nested":{"version":1,"createdAt":"c"}}"#,
80
+ // malformed JSON
81
+ r#"{"id":1,"name":"a""#,
82
+ r#"not json"#,
83
+ r#"{"id":1,}"#,
84
+ ];
85
+ assert_parity(schema, &docs);
86
+ }
87
+
88
+ #[test]
89
+ fn simple_type_parity() {
90
+ let schemas = [
91
+ json!({"type":"object"}),
92
+ json!({"type":"array"}),
93
+ json!({"type":"string"}),
94
+ json!({"type":"number"}),
95
+ json!({"type":"integer"}),
96
+ json!({"type":"boolean"}),
97
+ json!({"type":"null"}),
98
+ json!({"type":["string","number"]}),
99
+ json!({}),
100
+ json!(true),
101
+ json!(false),
102
+ ];
103
+ let docs = [
104
+ "{}",
105
+ "[]",
106
+ "\"x\"",
107
+ "42",
108
+ "42.5",
109
+ "1e2",
110
+ "true",
111
+ "false",
112
+ "null",
113
+ "{\"a\":1}",
114
+ "[1,2]",
115
+ "\"\"",
116
+ "-0",
117
+ "0.1",
118
+ ];
119
+ for s in &schemas {
120
+ assert_parity(s.clone(), &docs);
121
+ }
122
+ }
123
+
124
+ #[test]
125
+ fn string_length_parity() {
126
+ let schemas = [
127
+ json!({"type":"string","minLength":2}),
128
+ json!({"type":"string","maxLength":4}),
129
+ json!({"type":"string","minLength":1,"maxLength":3}),
130
+ ];
131
+ let docs = [
132
+ r#""ab""#,
133
+ r#""a""#,
134
+ r#""abcd""#,
135
+ r#""abcde""#,
136
+ r#""""#,
137
+ // escapes count as decoded length
138
+ r#""\u0041""#, // "A" -> len 1
139
+ r#""a\u0041b""#, // "aAb" -> len 3
140
+ r#""\n\t""#, // 2 chars
141
+ r#""\uD83D\uDE00""#, // 😀 -> len 1
142
+ ];
143
+ for s in &schemas {
144
+ assert_parity(s.clone(), &docs);
145
+ }
146
+ }
147
+
148
+ #[test]
149
+ fn number_bounds_parity() {
150
+ let schemas = [
151
+ json!({"type":"number","minimum":0,"maximum":10}),
152
+ json!({"type":"integer","minimum":1}),
153
+ json!({"type":"number","exclusiveMinimum":0.5}),
154
+ json!({"type":"number","exclusiveMaximum":5}),
155
+ json!({"type":"integer"}),
156
+ ];
157
+ let docs = [
158
+ "0",
159
+ "5",
160
+ "10",
161
+ "11",
162
+ "-1",
163
+ "0.5",
164
+ "0.5000001",
165
+ "1e1",
166
+ "1.5",
167
+ "2.0",
168
+ "-0",
169
+ "0.1",
170
+ "100",
171
+ ];
172
+ for s in &schemas {
173
+ assert_parity(s.clone(), &docs);
174
+ }
175
+ }
176
+
177
+ #[test]
178
+ fn array_parity() {
179
+ let schemas = [
180
+ json!({"type":"array","items":{"type":"string"}}),
181
+ json!({"type":"array","minItems":1,"maxItems":3}),
182
+ json!({"type":"array","items":{"type":"integer"},"minItems":2}),
183
+ ];
184
+ let docs = [
185
+ "[]",
186
+ "[\"a\"]",
187
+ "[\"a\",\"b\",\"c\"]",
188
+ "[\"a\",1]",
189
+ "[1]",
190
+ "[1,2,3]",
191
+ "[1,2]",
192
+ "[1.5]",
193
+ "[\"a\",\"b\",\"c\",\"d\"]",
194
+ "{}",
195
+ "null",
196
+ ];
197
+ for s in &schemas {
198
+ assert_parity(s.clone(), &docs);
199
+ }
200
+ }
201
+
202
+ #[test]
203
+ fn object_required_additional_parity() {
204
+ let schemas = [
205
+ json!({"required":["a"]}),
206
+ json!({"properties":{"a":{"type":"string"}}}),
207
+ json!({"properties":{"a":{"type":"string"}},"additionalProperties":false}),
208
+ json!({"properties":{"a":{"type":"string"}},"additionalProperties":{"type":"number"}}),
209
+ json!({"type":"object","minProperties":1,"maxProperties":2}),
210
+ json!({"required":["a","b"]}),
211
+ ];
212
+ let docs = [
213
+ "{}",
214
+ "{\"a\":1}",
215
+ "{\"a\":\"x\"}",
216
+ "{\"b\":1}",
217
+ "{\"a\":1,\"b\":2}",
218
+ "{\"a\":1,\"c\":true}",
219
+ "{\"c\":true}",
220
+ "{\"a\":\"x\",\"c\":\"s\"}",
221
+ "{\"a\":\"x\",\"c\":1}",
222
+ "{\"a\":\"x\",\"b\":\"y\"}",
223
+ "\"str\"",
224
+ "[]",
225
+ // duplicate keys (reference dedups, last wins)
226
+ "{\"a\":1,\"a\":2}",
227
+ "{\"x\":1,\"x\":2}",
228
+ // escaped keys
229
+ "{\"\\u0061\":\"x\"}", // key "a"
230
+ ];
231
+ for s in &schemas {
232
+ assert_parity(s.clone(), &docs);
233
+ }
234
+ }
235
+
236
+ #[test]
237
+ fn min_max_properties_distinct_parity() {
238
+ let schemas = [
239
+ json!({"type":"object","minProperties":1}),
240
+ json!({"type":"object","maxProperties":2}),
241
+ json!({"type":"object","minProperties":1,"maxProperties":2}),
242
+ // These force the inline → heap distinct-key migration (>16 keys or a
243
+ // >48-byte key) — the heap path must stay byte-parity with the crate.
244
+ json!({"type":"object","minProperties":16,"maxProperties":17}),
245
+ json!({"type":"object","minProperties":1,"maxProperties":1}),
246
+ ];
247
+ let docs = [
248
+ "{}",
249
+ "{\"a\":1}",
250
+ "{\"a\":1,\"b\":2}",
251
+ "{\"a\":1,\"b\":2,\"c\":3}",
252
+ // Duplicate keys: the distinct count differs from the member count.
253
+ "{\"a\":1,\"a\":2}",
254
+ // 17 distinct keys → exceeds the 16-slot inline tracker.
255
+ "{\"k0\":0,\"k1\":1,\"k2\":2,\"k3\":3,\"k4\":4,\"k5\":5,\"k6\":6,\"k7\":7,\"k8\":8,\"k9\":9,\"k10\":10,\"k11\":11,\"k12\":12,\"k13\":13,\"k14\":14,\"k15\":15,\"k16\":16}",
256
+ // Key longer than the 48-byte inline key buffer.
257
+ "{\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\":1}",
258
+ ];
259
+ for s in &schemas {
260
+ assert_parity(s.clone(), &docs);
261
+ }
262
+ }
263
+
264
+ #[test]
265
+ fn nested_composite_parity() {
266
+ let schemas = [json!({
267
+ "type": "object",
268
+ "properties": {
269
+ "data": {
270
+ "type": "object",
271
+ "required": ["x"],
272
+ "properties": { "x": { "type": "integer" } },
273
+ "additionalProperties": false
274
+ },
275
+ "list": {
276
+ "type": "array",
277
+ "items": { "type": "object", "properties": { "n": { "type": "number" } }, "required": ["n"] }
278
+ }
279
+ },
280
+ "required": ["data"]
281
+ })];
282
+ let docs = [
283
+ r#"{"data":{"x":1}}"#,
284
+ r#"{"data":{"x":"bad"}}"#,
285
+ r#"{"data":{"x":1,"y":2}}"#,
286
+ r#"{"data":{"x":1},"list":[{"n":1},{"n":2}]}"#,
287
+ r#"{"data":{"x":1},"list":[{"n":1},{"m":2}]}"#,
288
+ r#"{"data":{"x":1},"list":[]}"#,
289
+ r#"{}"#,
290
+ r#"{"data":{}}"#,
291
+ ];
292
+ assert_parity(schemas[0].clone(), &docs);
293
+ }
294
+
295
+ #[test]
296
+ fn unsupported_keywords_fall_back() {
297
+ // These must NOT compile on the fast path (caller falls back to DOM).
298
+ let unsupported = [
299
+ json!({"propertyNames":{"type":"string"}}),
300
+ json!({"format":"uri"}), // only format:email is fast-path
301
+ json!({"format":"date-time"}),
302
+ json!({"exclusiveMinimum":true}), // draft-04/06 boolean form
303
+ json!({"$ref":"other.json#/definitions/x"}), // external ref
304
+ json!({"$ref":"#anchor"}), // anchor ref (unsupported)
305
+ json!({"$schema":"http://json-schema.org/draft-04/schema#"}),
306
+ json!({"dependencies":{"a":{"type":"string"}}}), // schema form
307
+ json!({"enum":[1,2],"const":1}), // enum + const together
308
+ ];
309
+ for s in &unsupported {
310
+ assert!(compile(s).is_err(), "should fall back: {s}");
311
+ }
312
+ // Sanity: the supported bench schema DOES compile.
313
+ assert!(compile(&bench_schema()).is_ok());
314
+ }
315
+
316
+ // ── extended draft-07 keyword parity (fast vs jsonschema crate) ──────────
317
+
318
+ #[test]
319
+ fn pattern_parity() {
320
+ let schemas = [
321
+ json!({"type":"string","pattern":"^a"}),
322
+ json!({"type":"string","pattern":"\\d{3}-\\d{4}"}),
323
+ json!({"type":"string","pattern":"a.c"}),
324
+ // ECMA-262 lookahead (fancy-regex only; the regex crate would reject it)
325
+ json!({"type":"string","pattern":"foo(?=bar)"}),
326
+ json!({"type":"string","pattern":"^(a|b)+$"}),
327
+ ];
328
+ let docs = [
329
+ r#""abc""#,
330
+ r#""a""#,
331
+ r#""xabc""#,
332
+ r#""123-4567""#,
333
+ r#""12-34""#,
334
+ r#""foobar""#,
335
+ r#""foo""#,
336
+ r#""foobaz""#,
337
+ r#""ab""#,
338
+ r#""baba""#,
339
+ r#""ccc""#,
340
+ "42",
341
+ "null",
342
+ r#""\u0061bc""#, // "abc"
343
+ ];
344
+ for s in &schemas {
345
+ assert_parity(s.clone(), &docs);
346
+ }
347
+ }
348
+
349
+ #[test]
350
+ fn pattern_properties_parity() {
351
+ let schemas = [
352
+ json!({"type":"object","patternProperties":{"^x":{"type":"integer"}}}),
353
+ json!({
354
+ "type":"object",
355
+ "properties":{"foo":{"type":"string"}},
356
+ "patternProperties":{"^[a-z]+$":{"type":"number"}},
357
+ "additionalProperties":false
358
+ }),
359
+ json!({
360
+ "type":"object",
361
+ "patternProperties":{"S_":{"type":"string"}},
362
+ "additionalProperties":{"type":"number"}
363
+ }),
364
+ ];
365
+ let docs = [
366
+ r#"{"x1":1,"x2":2}"#,
367
+ r#"{"x1":"str"}"#,
368
+ r#"{"y1":1}"#,
369
+ r#"{"foo":"a","bar":1}"#,
370
+ r#"{"foo":"a","bar":"s"}"#,
371
+ r#"{"FOO":1}"#,
372
+ r#"{"S_a":"ok","other":1}"#,
373
+ r#"{"S_a":1}"#,
374
+ r#"{"other":"str"}"#,
375
+ r#"{}"#,
376
+ ];
377
+ for s in &schemas {
378
+ assert_parity(s.clone(), &docs);
379
+ }
380
+ }
381
+
382
+ #[test]
383
+ fn enum_const_parity() {
384
+ let schemas = [
385
+ json!({"enum":[1,2,3]}),
386
+ json!({"enum":["a","b","c"]}),
387
+ json!({"enum":[1,1.0,"1",true,null]}), // 1 and 1.0 are equal per JSON Schema
388
+ json!({"const":5}),
389
+ json!({"const":{"a":1,"b":2}}),
390
+ json!({"type":"integer","enum":[1,2]}),
391
+ json!({"enum":[{"x":[1,2]},{"x":[2,1]}]}),
392
+ ];
393
+ let docs = [
394
+ "1",
395
+ "2",
396
+ "3",
397
+ "4",
398
+ "1.0",
399
+ "1e0",
400
+ r#""a""#,
401
+ r#""b""#,
402
+ r#""d""#,
403
+ r#""""#,
404
+ "true",
405
+ "false",
406
+ "null",
407
+ r#"{"a":1,"b":2}"#,
408
+ r#"{"b":2,"a":1}"#,
409
+ r#"{"a":2,"b":2}"#,
410
+ r#"{"x":[1,2]}"#,
411
+ r#"{"x":[2,1]}"#,
412
+ r#"[1,2]"#,
413
+ ];
414
+ for s in &schemas {
415
+ assert_parity(s.clone(), &docs);
416
+ }
417
+ }
418
+
419
+ #[test]
420
+ fn multiple_of_parity() {
421
+ let schemas = [
422
+ json!({"type":"number","multipleOf":2}),
423
+ json!({"type":"number","multipleOf":0.01}),
424
+ json!({"type":"number","multipleOf":0.1}),
425
+ json!({"type":"integer","multipleOf":3}),
426
+ json!({"multipleOf":1.5}),
427
+ ];
428
+ let docs = [
429
+ "0", "2", "4", "3", "-2", "2.5", "1", "0.5", "0.01", "0.02", "0.03", "0.1", "1.23", "100",
430
+ "1.5", "3.0", "3.1", "4.5", "1e1", "1.25", "2.25", r#""x""#, "null", "{}",
431
+ ];
432
+ for s in &schemas {
433
+ assert_parity(s.clone(), &docs);
434
+ }
435
+ }
436
+
437
+ #[test]
438
+ fn multiple_of_non_finite_no_panic() {
439
+ // `1e999` parses to f64::INFINITY in `Cursor::number`. `is_multiple_of`
440
+ // must not panic (a panic through the raw C ABI crashes the host process)
441
+ // and must agree with the DOM validator, whose serde parse of `1e999`
442
+ // fails → invalid.
443
+ let schemas = [
444
+ json!({"type":"number","multipleOf":2}),
445
+ json!({"type":"number","multipleOf":0.1}),
446
+ json!({"type":"integer","multipleOf":3}),
447
+ ];
448
+ let docs = ["1e999", "-1e999", "1e308", "1.7976931348623157e308", "2"];
449
+ for s in &schemas {
450
+ assert_parity(s.clone(), &docs);
451
+ }
452
+ }
453
+
454
+ #[test]
455
+ fn combinators_parity() {
456
+ let schemas = [
457
+ json!({"allOf":[{"type":"number"},{"minimum":0}]}),
458
+ json!({"anyOf":[{"type":"string"},{"type":"integer"}]}),
459
+ json!({"oneOf":[{"type":"number"},{"minimum":0}]}),
460
+ json!({"not":{"type":"string"}}),
461
+ json!({"anyOf":[{"type":"null"},{"type":"array"}]}),
462
+ json!({"oneOf":[{"enum":[1,2]},{"minimum":1}]}),
463
+ json!({"allOf":[{"properties":{"a":{"type":"integer"}}},{"required":["a"]}]}),
464
+ ];
465
+ let docs = [
466
+ "5", "-5", r#""5""#, "0", "0.5", "null", r#""s""#, "1", "1.5", "[]", "{}", "2", "3", "-1",
467
+ ];
468
+ for s in &schemas {
469
+ assert_parity(s.clone(), &docs);
470
+ }
471
+ }
472
+
473
+ #[test]
474
+ fn if_then_else_parity() {
475
+ let schemas = [
476
+ json!({"type":"number","if":{"minimum":0},"then":{"multipleOf":2}}),
477
+ json!({
478
+ "if":{"properties":{"type":{"const":"a"}}},
479
+ "then":{"required":["a"]},
480
+ "else":{"required":["b"]}
481
+ }),
482
+ json!({"if":{"type":"string"},"else":{"type":"integer"}}),
483
+ json!({"if":false,"then":{"type":"string"}}),
484
+ ];
485
+ let docs = [
486
+ "2",
487
+ "3",
488
+ "-2",
489
+ "-3",
490
+ "0",
491
+ r#"{"type":"a","a":1}"#,
492
+ r#"{"type":"a"}"#,
493
+ r#"{"type":"b","b":1}"#,
494
+ r#"{"type":"b"}"#,
495
+ r#""hi""#,
496
+ "1",
497
+ "1.5",
498
+ "true",
499
+ "null",
500
+ ];
501
+ for s in &schemas {
502
+ assert_parity(s.clone(), &docs);
503
+ }
504
+ }
505
+
506
+ #[test]
507
+ fn unique_items_parity() {
508
+ let schemas = [
509
+ json!({"type":"array","uniqueItems":true}),
510
+ json!({"type":"array","uniqueItems":true,"items":{"type":"integer"}}),
511
+ json!({"type":"array","uniqueItems":true,"items":{"type":"object"}}),
512
+ ];
513
+ let docs = [
514
+ r#"[1,2,3]"#,
515
+ r#"[1,1]"#,
516
+ r#"[1,1.0]"#,
517
+ r#"[1,2,2.0]"#,
518
+ r#"["a","b"]"#,
519
+ r#"["a","a"]"#,
520
+ r#"[{"a":1},{"a":1}]"#,
521
+ r#"[{"a":1},{"b":1}]"#,
522
+ r#"[{"a":1,"b":2},{"b":2,"a":1}]"#,
523
+ r#"[true,false,true]"#,
524
+ r#"[[1],[1]]"#,
525
+ r#"[[1],[2]]"#,
526
+ r#"[]"#,
527
+ ];
528
+ for s in &schemas {
529
+ assert_parity(s.clone(), &docs);
530
+ }
531
+ }
532
+
533
+ #[test]
534
+ fn unique_items_bignum_parity() {
535
+ // Exercises the exact cross-type number comparison (num_cmp) — serde_json's
536
+ // Value PartialEq would wrongly treat these as equal via f64 casting.
537
+ let schema = json!({"type":"array","uniqueItems":true});
538
+ let docs = [
539
+ r#"[9007199254740993,9007199254740992]"#, // distinct large ints
540
+ r#"[9007199254740993,9007199254740993]"#, // duplicate
541
+ r#"[9007199254740993,9007199254740992.0]"#, // exact: NOT equal
542
+ r#"[1.0,1]"#,
543
+ r#"[18446744073709551615,18446744073709551615]"#, // u64 max duplicate
544
+ ];
545
+ assert_parity(schema, &docs);
546
+ }
547
+
548
+ #[test]
549
+ fn tuple_items_parity() {
550
+ let schemas = [
551
+ json!({"type":"array","items":[{"type":"string"},{"type":"integer"}]}),
552
+ json!({"type":"array","items":[{"type":"string"}],"additionalItems":false}),
553
+ json!({
554
+ "type":"array",
555
+ "items":[{"type":"string"},{"type":"integer"}],
556
+ "additionalItems":{"type":"boolean"}
557
+ }),
558
+ json!({"type":"array","items":[{"type":"string"}],"minItems":1}),
559
+ ];
560
+ let docs = [
561
+ r#"["a",1]"#,
562
+ r#"["a"]"#,
563
+ r#"[1,"a"]"#,
564
+ r#"["a","b"]"#,
565
+ r#"["a","b","c"]"#,
566
+ r#"["a",1,true]"#,
567
+ r#"["a",1,2]"#,
568
+ r#"[]"#,
569
+ r#"["a","b",1]"#,
570
+ ];
571
+ for s in &schemas {
572
+ assert_parity(s.clone(), &docs);
573
+ }
574
+ }
575
+
576
+ #[test]
577
+ fn contains_parity() {
578
+ let schemas = [
579
+ json!({"type":"array","contains":{"type":"integer"}}),
580
+ json!({"type":"array","contains":{"const":5}}),
581
+ json!({"type":"array","contains":{"type":"string"},"minItems":1}),
582
+ ];
583
+ let docs = [
584
+ r#"[1]"#,
585
+ r#"[1.5]"#,
586
+ r#"["a",1]"#,
587
+ r#"["a","b"]"#,
588
+ r#"[5]"#,
589
+ r#"[4,6]"#,
590
+ r#"[]"#,
591
+ r#"[{}]"#,
592
+ ];
593
+ for s in &schemas {
594
+ assert_parity(s.clone(), &docs);
595
+ }
596
+ }
597
+
598
+ #[test]
599
+ fn dependencies_parity() {
600
+ let schemas = [
601
+ json!({"type":"object","dependencies":{"a":["b"]}}),
602
+ json!({"type":"object","dependencies":{"a":["b","c"]}}),
603
+ json!({"type":"object","dependencies":{"a":[],"b":["a"]}}),
604
+ ];
605
+ let docs = [
606
+ r#"{"a":1,"b":2}"#,
607
+ r#"{"a":1}"#,
608
+ r#"{"b":2}"#,
609
+ r#"{}"#,
610
+ r#"{"a":1,"b":2,"c":3}"#,
611
+ r#"{"a":1,"c":3}"#,
612
+ r#"{"b":1,"a":2}"#,
613
+ ];
614
+ for s in &schemas {
615
+ assert_parity(s.clone(), &docs);
616
+ }
617
+ }
618
+
619
+ #[test]
620
+ fn ref_parity() {
621
+ let schemas = [
622
+ json!({
623
+ "definitions": {"positive": {"type":"number","minimum":0}},
624
+ "$ref":"#/definitions/positive"
625
+ }),
626
+ json!({
627
+ "$defs": {
628
+ "user": {
629
+ "type":"object",
630
+ "required":["id"],
631
+ "properties":{"id":{"type":"integer"}}
632
+ }
633
+ },
634
+ "type":"object",
635
+ "properties":{
636
+ "owner":{"$ref":"#/$defs/user"},
637
+ "admin":{"$ref":"#/$defs/user"}
638
+ }
639
+ }),
640
+ json!({
641
+ "definitions": {
642
+ "a": {"type":"string"},
643
+ "b": {"allOf":[{"$ref":"#/definitions/a"},{"minLength":2}]}
644
+ },
645
+ "$ref":"#/definitions/b"
646
+ }),
647
+ json!({
648
+ "type":"object",
649
+ "properties":{"x":{"type":"object","properties":{"y":{"type":"string"}}}},
650
+ "$ref":"#/properties/x/properties/y"
651
+ }),
652
+ ];
653
+ let docs = [
654
+ "1",
655
+ "-1",
656
+ "0",
657
+ "5.5",
658
+ r#""s""#,
659
+ r#"{"id":1}"#,
660
+ r#"{"id":"x"}"#,
661
+ r#"{"owner":{"id":1},"admin":{"id":2}}"#,
662
+ r#"{"owner":{"id":1},"admin":{}}"#,
663
+ r#""abc""#,
664
+ r#""a""#,
665
+ r#""""#,
666
+ r#""x""#,
667
+ "123",
668
+ ];
669
+ for s in &schemas {
670
+ assert_parity(s.clone(), &docs);
671
+ }
672
+ }
673
+
674
+ #[test]
675
+ fn dollar_schema_guard() {
676
+ // Non-draft-07 `$schema` declarations fall back (semantics diverge).
677
+ for ss in [
678
+ "http://json-schema.org/draft-04/schema#",
679
+ "http://json-schema.org/draft-06/schema#",
680
+ "https://json-schema.org/draft/2020-12/schema",
681
+ "https://json-schema.org/draft/2019-09/schema",
682
+ ] {
683
+ let schema = json!({"$schema": ss, "type": "object"});
684
+ assert!(compile(&schema).is_err(), "$schema={ss} must fall back");
685
+ }
686
+ // draft-07 (with or without the URL form) compiles.
687
+ for ss in [
688
+ "http://json-schema.org/draft-07/schema#",
689
+ "https://json-schema.org/draft-07/schema",
690
+ ] {
691
+ let schema = json!({"$schema": ss, "type": "object"});
692
+ assert!(compile(&schema).is_ok(), "$schema={ss} must compile");
693
+ }
694
+ }
695
+
696
+ #[test]
697
+ fn draft07_unknown_keywords_ignored() {
698
+ // 2020-12-only keywords are UNKNOWN to draft-07, so the reference ignores
699
+ // them; the fast path must too (compile + behave identically).
700
+ for schema in [
701
+ json!({"type":"object","prefixItems":[{"type":"string"}]}),
702
+ json!({"type":"array","unevaluatedItems":false}),
703
+ json!({"type":"object","unevaluatedProperties":false}),
704
+ json!({"type":"object","dependentRequired":{"a":["b"]}}),
705
+ json!({"type":"array","minContains":1}),
706
+ ] {
707
+ assert!(
708
+ compile(&schema).is_ok(),
709
+ "should compile (ignored keyword): {schema}"
710
+ );
711
+ assert_parity(schema, &[r#"{}"#, r#"[]"#, r#"[1,2]"#, r#"{"a":1}"#]);
712
+ }
713
+ }
714
+
715
+ // ── detailed error reporting (fast path) ────────────────────────────────
716
+
717
+ #[test]
718
+ fn error_reporting_paths_and_keywords() {
719
+ let schema = json!({
720
+ "type":"object",
721
+ "required":["id","name"],
722
+ "properties":{
723
+ "id":{"type":"integer"},
724
+ "name":{"type":"string","minLength":2,"pattern":"^[a-z]+$"},
725
+ "tags":{"type":"array","items":{"type":"string"},"uniqueItems":true}
726
+ },
727
+ "additionalProperties":false
728
+ });
729
+ let fast = compile(&schema).unwrap();
730
+
731
+ // valid -> no errors
732
+ assert!(fast
733
+ .validate_errors(br#"{"id":1,"name":"ab","tags":["x","y"]}"#, usize::MAX)
734
+ .is_empty());
735
+
736
+ // missing required -> keyword required, root pointer
737
+ let errs = fast.validate_errors(br#"{"id":1}"#, usize::MAX);
738
+ let req = errs.iter().find(|e| e.keyword == "required").unwrap();
739
+ assert_eq!(req.instance_path, "");
740
+ assert!(req.message.contains("name"));
741
+
742
+ // wrong type at /id
743
+ let errs = fast.validate_errors(br#"{"id":"x","name":"ab"}"#, usize::MAX);
744
+ let t = errs.iter().find(|e| e.keyword == "type").unwrap();
745
+ assert_eq!(t.instance_path, "/id");
746
+
747
+ // pattern at /name
748
+ let errs = fast.validate_errors(br#"{"id":1,"name":"AB"}"#, usize::MAX);
749
+ let p = errs.iter().find(|e| e.keyword == "pattern").unwrap();
750
+ assert_eq!(p.instance_path, "/name");
751
+ assert!(p.schema_path.ends_with("/properties/name/pattern"));
752
+
753
+ // additionalProperties at the offending key
754
+ let errs = fast.validate_errors(br#"{"id":1,"name":"ab","extra":true}"#, usize::MAX);
755
+ let ap = errs
756
+ .iter()
757
+ .find(|e| e.keyword == "additionalProperties")
758
+ .unwrap();
759
+ assert_eq!(ap.instance_path, "/extra");
760
+
761
+ // uniqueItems at /tags
762
+ let errs = fast.validate_errors(br#"{"id":1,"name":"ab","tags":["x","x"]}"#, usize::MAX);
763
+ let u = errs.iter().find(|e| e.keyword == "uniqueItems").unwrap();
764
+ assert_eq!(u.instance_path, "/tags");
765
+
766
+ // multi-error collection: every failing property is reported
767
+ let errs = fast.validate_errors(br#"{"id":"x","name":"AB","extra":1}"#, usize::MAX);
768
+ let kw: Vec<&str> = errs.iter().map(|e| e.keyword.as_str()).collect();
769
+ assert!(
770
+ kw.contains(&"type") && kw.contains(&"pattern") && kw.contains(&"additionalProperties"),
771
+ "expected type+pattern+additionalProperties errors, got {kw:?}"
772
+ );
773
+
774
+ // max_errors=1 returns exactly the first error
775
+ let first = fast.validate_errors(br#"{"id":"x","name":""}"#, 1);
776
+ assert_eq!(first.len(), 1);
777
+
778
+ // RFC 6901 escaping: a key with a slash / tilde in the pointer
779
+ let schema2 = json!({
780
+ "type":"object",
781
+ "properties":{"a/b":{"type":"integer"},"c~d":{"type":"integer"}}
782
+ });
783
+ let fast2 = compile(&schema2).unwrap();
784
+ let errs = fast2.validate_errors(br#"{"a/b":"x"}"#, usize::MAX);
785
+ assert!(errs.iter().any(|e| e.instance_path == "/a~1b"));
786
+ let errs = fast2.validate_errors(br#"{"c~d":"x"}"#, usize::MAX);
787
+ assert!(errs.iter().any(|e| e.instance_path == "/c~0d"));
788
+ }
789
+
790
+ #[test]
791
+ fn error_reporting_matches_validation_bool() {
792
+ // validate_errors(empty) must agree exactly with is_valid_bytes.
793
+ let schemas = [
794
+ json!({
795
+ "type":"object",
796
+ "required":["a"],
797
+ "properties":{"a":{"type":"number","multipleOf":2}},
798
+ "additionalProperties":false
799
+ }),
800
+ json!({"anyOf":[{"type":"string"},{"type":"integer"}]}),
801
+ json!({"type":"array","items":{"type":"string"},"minItems":1}),
802
+ ];
803
+ let docs = [
804
+ r#"{"a":4}"#,
805
+ r#"{"a":3}"#,
806
+ r#"{"a":"x"}"#,
807
+ r#"{"b":1}"#,
808
+ r#""s""#,
809
+ "1",
810
+ r#"["a"]"#,
811
+ "[]",
812
+ r#"["a",1]"#,
813
+ "not json",
814
+ ];
815
+ for s in &schemas {
816
+ let fast = compile(s).unwrap();
817
+ for doc in &docs {
818
+ let ok = fast.is_valid_bytes(doc.as_bytes());
819
+ let errs = fast.validate_errors(doc.as_bytes(), usize::MAX);
820
+ assert_eq!(ok, errs.is_empty(), "schema={s} doc={doc}");
821
+ }
822
+ }
823
+ }
824
+
825
+ #[test]
826
+ fn invalid_utf8_and_malformed_bytes_return_false() {
827
+ // The fast path is byte-oriented; it must reject structurally invalid or
828
+ // non-UTF-8-leading documents without panicking (matching the DOM path's
829
+ // parse failure → false).
830
+ let fast = compile(&bench_schema()).unwrap();
831
+
832
+ // Non-UTF-8 leading bytes cannot start any JSON value.
833
+ assert!(!fast.is_valid_bytes(&[0xFF, 0xFE]));
834
+ assert!(!fast.is_valid_bytes(&[0x80]));
835
+ assert!(!fast.is_valid_bytes(&[0xC0, 0xAF]));
836
+
837
+ // Truncated structural input.
838
+ assert!(!fast.is_valid_bytes(b"{\"id\":1"));
839
+ assert!(!fast.is_valid_bytes(b"{\"id\":"));
840
+ assert!(!fast.is_valid_bytes(b"[1,2"));
841
+
842
+ // Trailing garbage after a valid document (must consume ALL bytes).
843
+ assert!(!fast.is_valid_bytes(b"{\"id\":1}\x00"));
844
+ assert!(!fast.is_valid_bytes(
845
+ b"{\"id\":1,\"name\":\"a\",\"active\":true,\"score\":1,\"tags\":[],\"nested\":{\"version\":1,\"createdAt\":\"c\"}} garbage"
846
+ ));
847
+ }
848
+
849
+ #[test]
850
+ fn rfc8259_strictness_matches_sonic_gate() {
851
+ // The fast path now serves as the RFC-8259 well-formedness gate for
852
+ // fast-path schemas in the ingress pipeline (pipeline.rs stage 6): a pass
853
+ // means BOTH well-formed JSON AND schema-conforming, so the separate sonic
854
+ // `json_valid_bytes` pass is skipped on the happy path. The safety
855
+ // property this pins: the fast path must NEVER accept a body the strict
856
+ // sonic gate rejects (`!sonic ⇒ !fast`). The one gap the walk closes is
857
+ // raw control bytes (< 0x20) in strings; sonic is lenient on exactly what
858
+ // the walk is lenient on (bad `\uXXXX` hex, lone surrogates, invalid
859
+ // UTF-8, numeric overflow) — so this corpus must not diverge.
860
+ let fast = compile(&bench_schema()).unwrap();
861
+
862
+ let corpus: &[&[u8]] = &[
863
+ // Raw control bytes in string VALUES (sonic rejects → fast must too).
864
+ b"{\"id\":1,\"name\":\"a\x01b\"}",
865
+ b"{\"id\":1,\"name\":\"a\x00\"}",
866
+ b"{\"id\":1,\"name\":\"\x1f\"}",
867
+ b"{\"id\":1,\"name\":\"a\tb\"}", // raw tab inside a string
868
+ // Raw control bytes in KEYS (both route through raw_string).
869
+ b"{\"id\x09\":1}",
870
+ b"{\"\x00\":1}",
871
+ // Other malformed-JSON classes sonic rejects.
872
+ b"{\"id\":1,}",
873
+ b"[1,]",
874
+ b"{\"id\":1",
875
+ b"{\"id\":}",
876
+ b"-01",
877
+ b"1.",
878
+ b"01",
879
+ b"",
880
+ b"{\"id\":1} {\"id\":2}",
881
+ b"{\"id\":1}\x00",
882
+ // Sonic-lenient inputs the walk must ALSO accept (parity, not strict).
883
+ b"{\"id\":1,\"name\":\"\\uZZZZ\"}", // bad \u hex
884
+ b"{\"id\":1,\"name\":\"\\uD800\"}", // lone surrogate
885
+ b"{\"id\":1,\"name\":\"a\xFFb\"}", // invalid UTF-8 in a string
886
+ b"1e999", // numeric overflow
887
+ // Valid AND schema-conforming positive control (bench_schema requires
888
+ // id/name/active/score/tags/nested — the other two- and three-field
889
+ // docs above are well-formed but non-conforming, which the pipeline
890
+ // splits as 422 via the sonic re-check).
891
+ b"{\"id\":1,\"name\":\"user_1\",\"active\":true,\"score\":1.25,\"tags\":[\"alpha\",\"beta\"],\"nested\":{\"version\":1,\"createdAt\":\"2026-01-01T00:00:00Z\"}}",
892
+ b"\t\r\n {}", // whitespace control OUTSIDE strings is valid
893
+ ];
894
+
895
+ for &doc in corpus {
896
+ let sonic = crate::json::json_ops::json_valid_bytes(doc);
897
+ let got = fast.is_valid_bytes(doc);
898
+ if !sonic {
899
+ assert!(
900
+ !got,
901
+ "fast path accepted a body the sonic gate rejects: {:?}",
902
+ String::from_utf8_lossy(doc)
903
+ );
904
+ }
905
+ }
906
+ // Positive control: well-formed + conforming must pass the fast path.
907
+ assert!(fast.is_valid_bytes(
908
+ b"{\"id\":1,\"name\":\"user_1\",\"active\":true,\"score\":1.25,\"tags\":[\"alpha\",\"beta\"],\"nested\":{\"version\":1,\"createdAt\":\"2026-01-01T00:00:00Z\"}}"
909
+ ));
910
+ // Well-formed but non-conforming (missing required field) may reject —
911
+ // the pipeline splits that as 422 via the sonic re-check.
912
+ assert!(!fast.is_valid_bytes(b"{\"id\":1}"));
913
+ }
914
+
915
+ // ── format: "email" (zero-DOM fast path) ─────────────────────────
916
+
917
+ #[test]
918
+ fn email_format_matches_reference_corpus() {
919
+ let schema = json!({
920
+ "type": "object",
921
+ "required": ["email"],
922
+ "properties": {
923
+ "email": { "type": "string", "format": "email" }
924
+ },
925
+ "additionalProperties": false
926
+ });
927
+ let docs = [
928
+ // ── valid ──
929
+ r#"{"email":"a@b.com"}"#,
930
+ r#"{"email":"john.doe+tag@example.co.uk"}"#,
931
+ r#"{"email":"user@sub.example.com"}"#,
932
+ r#"{"email":"user@[IPv6:2001:db8::1]"}"#,
933
+ r#"{"email":"user@[192.168.0.1]"}"#,
934
+ r#"{"email":"user@[IPv6:2001:0db8:85a3:0000:0000:8a2e:0370:7334]"}"#,
935
+ r#"{"email":"user@xn--bcher-kva.com"}"#, // valid punycode domain
936
+ r#"{"email":"a.b.c.d.e.f@example.com"}"#,
937
+ r#"{"email":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@example.com"}"#, // 64-char local
938
+ r#"{"email":"user@xn--11b2ezcw70k"}"#, // ZWJ after virama (valid punycode)
939
+ r#"{"email":"user@xn--ll-0ea"}"#, // punycode with valid middle dot context
940
+ // ── invalid: domain shape ──
941
+ r#"{"email":"not-an-email"}"#,
942
+ r#"{"email":"a@"}"#,
943
+ r#"{"email":"@b.com"}"#,
944
+ r#"{"email":"a b@c.com"}"#,
945
+ r#"{"email":"a@b c.com"}"#,
946
+ r#"{"email":"a@-b.com"}"#,
947
+ r#"{"email":"a@b-.com"}"#,
948
+ r#"{"email":"a@b..com"}"#,
949
+ r#"{"email":"a@b.com."}"#,
950
+ r#"{"email":"a@ex--ample.com"}"#, // hyphens 3rd+4th, not punycode
951
+ r#"{"email":"a@xn--example.com"}"#, // invalid punycode
952
+ r#"{"email":"a@xn--x"}"#, // too-short punycode
953
+ r#"{"email":"a@[IPv6:zzzz::1]"}"#,
954
+ r#"{"email":"a@[999.999.999.999]"}"#,
955
+ r#"{"email":"a@[not-a-literal]"}"#,
956
+ r#"{"email":"a@xn--hello-zed"}"#, // punycode beginning with nonspacing mark
957
+ r#"{"email":"a@XN--aa---o47jg78q"}"#, // uppercase punycode prefix rejected
958
+ r#"{"email":"a@example-.com"}"#,
959
+ // ── invalid: local part / escapes ──
960
+ r#"{"email":"a\u0040b.com"}"#, // JSON escape decodes to a@b.com (valid) — decode path
961
+ r#"{"email":"a\\@b.com"}"#, // backslash in local part
962
+ r#"{"email":"héllo@example.com"}"#, // non-ASCII local part rejected (format:email)
963
+ // wrong root type
964
+ r#"42"#,
965
+ ];
966
+ assert_parity(schema, &docs);
967
+ }
968
+
969
+ #[test]
970
+ fn email_only_schema_compiles_and_validates() {
971
+ // A schema with ONLY format:email (no minLength/maxLength) must still
972
+ // engage the fast path's string node.
973
+ let schema = json!({ "type": "string", "format": "email" });
974
+ let fast = compile(&schema).expect("format:email must compile on the fast path");
975
+ let v = jsonschema::options()
976
+ .with_draft(jsonschema::Draft::Draft7)
977
+ .should_validate_formats(true)
978
+ .build(&schema)
979
+ .unwrap();
980
+ for doc in [r#""a@b.com""#, r#""not-an-email""#, r#"42"#] {
981
+ let expected = serde_json::from_str::<Value>(doc)
982
+ .map(|x| v.is_valid(&x))
983
+ .unwrap_or(false);
984
+ assert_eq!(fast.is_valid_bytes(doc.as_bytes()), expected, "doc={doc}");
985
+ }
986
+ }
987
+
988
+ #[test]
989
+ fn other_formats_and_unknown_formats_fall_back() {
990
+ for f in [
991
+ "idn-email",
992
+ "date-time",
993
+ "uri",
994
+ "uuid",
995
+ "ipv4",
996
+ "regex",
997
+ "unknown",
998
+ ] {
999
+ let schema = json!({ "type": "string", "format": f });
1000
+ assert!(
1001
+ compile(&schema).is_err(),
1002
+ "format {f:?} must fall back to the DOM path"
1003
+ );
1004
+ }
1005
+ }
1006
+
1007
+ #[test]
1008
+ fn email_format_property_parity_with_reference() {
1009
+ // Deterministic pseudo-random emails: cross-check the fast path against the
1010
+ // authoritative jsonschema DOM validator. ANY divergence fails — this is
1011
+ // the byte-parity gate that justifies engaging the fast path for the
1012
+ // ingress schema (which uses format:email).
1013
+ let schema = json!({ "type": "string", "format": "email" });
1014
+ let fast = compile(&schema).unwrap();
1015
+ let v = jsonschema::options()
1016
+ .with_draft(jsonschema::Draft::Draft7)
1017
+ .should_validate_formats(true)
1018
+ .build(&schema)
1019
+ .unwrap();
1020
+
1021
+ // Simple LCG (no rand dep needed in tests).
1022
+ let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
1023
+ let mut next = move || {
1024
+ state = state
1025
+ .wrapping_mul(6364136223846793005)
1026
+ .wrapping_add(1442695040888963407);
1027
+ (state >> 32) as u32
1028
+ };
1029
+
1030
+ let alphabet_local: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789._%+-";
1031
+ let alphabet_domain: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789-.";
1032
+ let alphabet_bad: &[u8] = b" @!?*\\/()";
1033
+
1034
+ let mut checked = 0u32;
1035
+ for _ in 0..3000 {
1036
+ // Local part: 0..=24 chars, mostly valid + occasional bad char.
1037
+ let mut local = String::new();
1038
+ let llen = (next() % 26) as usize;
1039
+ for _ in 0..llen {
1040
+ let pick = next() % 100;
1041
+ let b = if pick < 88 {
1042
+ alphabet_local[(next() % alphabet_local.len() as u32) as usize]
1043
+ } else {
1044
+ alphabet_bad[(next() % alphabet_bad.len() as u32) as usize]
1045
+ };
1046
+ local.push(b as char);
1047
+ }
1048
+ // Domain: 0..=40 chars, occasionally a space or trailing dot.
1049
+ let mut domain = String::new();
1050
+ let dlen = (next() % 42) as usize;
1051
+ for _ in 0..dlen {
1052
+ let pick = next() % 100;
1053
+ let b = if pick < 92 {
1054
+ alphabet_domain[(next() % alphabet_domain.len() as u32) as usize]
1055
+ } else {
1056
+ b' '
1057
+ };
1058
+ domain.push(b as char);
1059
+ }
1060
+ let mut email = local;
1061
+ email.push('@');
1062
+ email.push_str(&domain);
1063
+
1064
+ let doc = serde_json::to_string(&email).unwrap();
1065
+ let expected = serde_json::from_str::<Value>(&doc)
1066
+ .map(|x| v.is_valid(&x))
1067
+ .unwrap_or(false);
1068
+ let got = fast.is_valid_bytes(doc.as_bytes());
1069
+ assert_eq!(got, expected, "email={email:?}");
1070
+ checked += 1;
1071
+ }
1072
+ assert!(checked >= 3000);
1073
+ }
1074
+
1075
+ // ── hostile nesting depth (stack-overflow guard) ─────────────────
1076
+
1077
+ #[test]
1078
+ // The repeated `push` of the same structural literal is deliberate — it
1079
+ // builds a deeply-nested document to exercise the stack-overflow guard.
1080
+ #[allow(clippy::same_item_push)]
1081
+ fn deeply_nested_documents_are_bounded_not_crashing() {
1082
+ // A ~10K-deep document must be REJECTED, not recursed into a stack overflow
1083
+ // (a stack overflow aborts the whole process — panic=unwind + napi
1084
+ // catch_unwind do NOT catch it). The any-schema walks every nested value
1085
+ // via Cursor::skip_value, which is exactly the recursion path a hostile
1086
+ // body would drive.
1087
+ let any = FastNode::any();
1088
+
1089
+ let mut deep_arr = Vec::with_capacity(20_000);
1090
+ for _ in 0..10_000 {
1091
+ deep_arr.push(b'[');
1092
+ }
1093
+ deep_arr.push(b'0');
1094
+ for _ in 0..10_000 {
1095
+ deep_arr.push(b']');
1096
+ }
1097
+ assert!(!any.is_valid_bytes(&deep_arr));
1098
+
1099
+ let mut deep_obj = Vec::with_capacity(60_000);
1100
+ for _ in 0..10_000 {
1101
+ deep_obj.extend_from_slice(b"{\"a\":");
1102
+ }
1103
+ deep_obj.push(b'0');
1104
+ for _ in 0..10_000 {
1105
+ deep_obj.push(b'}');
1106
+ }
1107
+ assert!(!any.is_valid_bytes(&deep_obj));
1108
+
1109
+ // Sanity: an ordinary payload is still valid — the cap must not reject
1110
+ // normal documents.
1111
+ assert!(any.is_valid_bytes(b"{\"a\":[1,{\"b\":2}]}"));
1112
+ }
1113
+
1114
+ #[test]
1115
+ fn deep_nesting_parity_with_reference() {
1116
+ // The fast path caps at MAX_DEPTH (128), matching sonic-rs (the ingress
1117
+ // gate) and serde_json (the DOM reference): deeper documents parse-fail in
1118
+ // the reference too, so validity must agree.
1119
+ let schema = json!({ "type": "object", "additionalProperties": true });
1120
+ let fast = compile(&schema).unwrap();
1121
+ let v = jsonschema::options()
1122
+ .with_draft(jsonschema::Draft::Draft7)
1123
+ .build(&schema)
1124
+ .unwrap();
1125
+
1126
+ // 100-deep (below the cap) and 200-deep (above it) — both must agree with
1127
+ // the reference.
1128
+ for depth in [100usize, 200] {
1129
+ let mut doc = String::new();
1130
+ for _ in 0..depth {
1131
+ doc.push_str("{\"a\":");
1132
+ }
1133
+ doc.push('0');
1134
+ for _ in 0..depth {
1135
+ doc.push('}');
1136
+ }
1137
+ let expected = serde_json::from_str::<Value>(&doc)
1138
+ .map(|x| v.is_valid(&x))
1139
+ .unwrap_or(false);
1140
+ let got = fast.is_valid_bytes(doc.as_bytes());
1141
+ assert_eq!(got, expected, "depth={depth}");
1142
+ }
1143
+ }