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,931 @@
1
+ // rust/json/fast_schema/validate.rs — AST + raw bytes → validation (bool or
2
+ // detailed errors via an optional `Ctx` error sink).
3
+ //
4
+ // Walks a compiled `FastNode` over raw JSON bytes with a `Cursor`. In bool mode
5
+ // (`Ctx::bool_mode`) the walk is allocation-free for the core subset (matching
6
+ // the original `is_valid_bytes` hot path used by the ingress); in detailed mode
7
+ // it additionally collects `SchemaError`s (instance pointer + keyword + message).
8
+
9
+ use super::cursor::{count_chars, decode_string, Cursor};
10
+ use super::errors::Ctx;
11
+ use super::types::{
12
+ kind_of, Additional, FastArray, FastNode, FastObject, Kind, T_ARR, T_BOOL, T_INT, T_NULL,
13
+ T_NUM, T_OBJ, T_STR,
14
+ };
15
+ use fraction::One;
16
+
17
+ use crate::json::napi_marshal::sonic_values_equal;
18
+
19
+ impl FastNode {
20
+ pub(crate) fn any() -> Self {
21
+ Self::default()
22
+ }
23
+
24
+ pub(crate) fn never() -> Self {
25
+ Self {
26
+ never: true,
27
+ ..Self::default()
28
+ }
29
+ }
30
+
31
+ /// Validate a complete JSON document against this schema (bool mode).
32
+ #[inline]
33
+ pub fn is_valid_bytes(&self, bytes: &[u8]) -> bool {
34
+ let mut c = Cursor::new(bytes);
35
+ let mut ctx = Ctx::bool_mode();
36
+ if !self.validate(&mut c, &mut ctx) {
37
+ return false;
38
+ }
39
+ c.skip_ws();
40
+ c.pos == bytes.len()
41
+ }
42
+
43
+ /// Validate a complete JSON document against this schema while capturing
44
+ /// scalar values at `targets` during the SAME walk (one pass, no DOM).
45
+ ///
46
+ /// Returns `(schema_valid, capture)`. When invalid, captured values are
47
+ /// meaningless (callers must reject); when valid, each target either
48
+ /// matched (range/length) or the path was absent from the document.
49
+ pub fn validate_with_capture(
50
+ &self,
51
+ bytes: &[u8],
52
+ targets: Vec<super::capture::TargetPath>,
53
+ ) -> (bool, super::capture::Capture) {
54
+ let capture = super::capture::Capture::new(targets);
55
+ let mut c = Cursor::new(bytes);
56
+ let mut ctx = Ctx::bool_mode();
57
+ ctx.capture = Some(capture);
58
+ ctx.input_ptr = bytes.as_ptr();
59
+ let ok = self.validate(&mut c, &mut ctx);
60
+ c.skip_ws();
61
+ let ok = ok && c.pos == bytes.len();
62
+ // `capture` was set just above, so `take` is guaranteed Some.
63
+ let capture = ctx.capture.take().expect("capture set before validate");
64
+ (ok, capture)
65
+ }
66
+
67
+ /// Validate and return detailed errors (empty = valid). `max_errors` caps
68
+ /// the number of errors collected (1 = first error only).
69
+ pub fn validate_errors(
70
+ &self,
71
+ bytes: &[u8],
72
+ max_errors: usize,
73
+ ) -> Vec<super::errors::SchemaError> {
74
+ let mut c = Cursor::new(bytes);
75
+ let mut ctx = Ctx::detailed(max_errors);
76
+ if self.validate(&mut c, &mut ctx) {
77
+ c.skip_ws();
78
+ if c.pos == bytes.len() {
79
+ return Vec::new();
80
+ }
81
+ ctx.record("", "parse", || {
82
+ "trailing data after the JSON value".to_string()
83
+ });
84
+ }
85
+ ctx.into_errors()
86
+ }
87
+
88
+ /// Validate `c` against this schema (bool or detailed mode). Every
89
+ /// recursion of the schema walk (members, tuple items, combinator
90
+ /// sub-schemas) routes through this method, so it is the single choke point
91
+ /// for the nesting-depth cap: `MAX_DEPTH` bounds how deep the walk can
92
+ /// recurse, preventing hostile deeply-nested JSON from exhausting the
93
+ /// native stack (an uncatchable abort — see `Ctx::enter_depth`).
94
+ fn validate(&self, c: &mut Cursor, ctx: &mut Ctx) -> bool {
95
+ if !ctx.enter_depth() {
96
+ ctx.record("", "maxDepth", || {
97
+ "maximum JSON nesting depth exceeded".to_string()
98
+ });
99
+ return false;
100
+ }
101
+ let ok = self.validate_inner(c, ctx);
102
+ ctx.leave_depth();
103
+ ok
104
+ }
105
+
106
+ fn validate_inner(&self, c: &mut Cursor, ctx: &mut Ctx) -> bool {
107
+ let base = self.schema_path.as_deref().unwrap_or("");
108
+ if self.never {
109
+ ctx.record(base, "false schema", || {
110
+ "the schema is false and rejects every value".to_string()
111
+ });
112
+ return false;
113
+ }
114
+ c.skip_ws();
115
+ let vstart = c.pos;
116
+ let Some(b) = c.peek() else {
117
+ ctx.record(base, "type", || {
118
+ "expected a JSON value but found end of input".to_string()
119
+ });
120
+ return false;
121
+ };
122
+ // 1. `type`
123
+ if self.types != 0 {
124
+ let Some(k) = kind_of(b) else {
125
+ ctx.record(base, "type", || "unexpected JSON token".to_string());
126
+ return false;
127
+ };
128
+ let ok = match k {
129
+ Kind::Number => self.types & (T_NUM | T_INT) != 0,
130
+ Kind::Null => self.types & T_NULL != 0,
131
+ Kind::Boolean => self.types & T_BOOL != 0,
132
+ Kind::String => self.types & T_STR != 0,
133
+ Kind::Array => self.types & T_ARR != 0,
134
+ Kind::Object => self.types & T_OBJ != 0,
135
+ };
136
+ if !ok {
137
+ ctx.record(base, "type", || {
138
+ format!(
139
+ "expected {} but found {}",
140
+ type_names(self.types),
141
+ kind_name(k)
142
+ )
143
+ });
144
+ return false;
145
+ }
146
+ }
147
+ // 2. `enum` / `const` — compare parsed values, matching the reference
148
+ // (exact number equality; object keys compared in insertion order,
149
+ // replicating jsonschema's cmp::equal).
150
+ if let Some(value) = &self.value {
151
+ let data = c.data;
152
+ if !c.skip_value(ctx.depth) {
153
+ ctx.record(base, "parse", || "malformed JSON value".to_string());
154
+ return false;
155
+ }
156
+ let raw = &data[vstart..c.pos];
157
+ let val: sonic_rs::Value = match sonic_rs::from_slice(raw) {
158
+ Ok(v) => v,
159
+ Err(_) => {
160
+ ctx.record(base, "parse", || "malformed JSON value".to_string());
161
+ return false;
162
+ }
163
+ };
164
+ let (keyword, ok, message) = match value {
165
+ super::types::ValueConstraint::Enum(members) => (
166
+ "enum",
167
+ members.iter().any(|m| sonic_values_equal(m, &val)),
168
+ "value is not one of the allowed enum values",
169
+ ),
170
+ super::types::ValueConstraint::Const(x) => (
171
+ "const",
172
+ sonic_values_equal(x, &val),
173
+ "value is not equal to the required constant",
174
+ ),
175
+ };
176
+ if !ok {
177
+ ctx.record(base, keyword, || message.to_string());
178
+ return false;
179
+ }
180
+ // Run the structural + combinator constraints over the same value.
181
+ let mut c2 = Cursor::new(raw);
182
+ return self.validate_kind(&mut c2, ctx) && self.validate_comb_raw(raw, ctx);
183
+ }
184
+ // 3. kind-specific structural constraints
185
+ if !self.validate_kind(c, ctx) {
186
+ return false;
187
+ }
188
+ // 4. combinators over the same value (re-scanned from vstart)
189
+ let data = c.data;
190
+ let raw = &data[vstart..c.pos];
191
+ self.validate_comb_raw(raw, ctx)
192
+ }
193
+
194
+ fn validate_kind(&self, c: &mut Cursor, ctx: &mut Ctx) -> bool {
195
+ let base = self.schema_path.as_deref().unwrap_or("");
196
+ let Some(b) = c.peek() else {
197
+ ctx.record(base, "type", || "expected a JSON value".to_string());
198
+ return false;
199
+ };
200
+ match b {
201
+ b'{' => match &self.obj {
202
+ Some(o) => validate_object(o, c, ctx, base),
203
+ None => c.skip_value(ctx.depth),
204
+ },
205
+ b'[' => match &self.arr {
206
+ Some(a) => validate_array(a, c, ctx, base),
207
+ None => c.skip_value(ctx.depth),
208
+ },
209
+ b'"' => {
210
+ let Some(inner) = c.raw_string() else {
211
+ ctx.record(base, "parse", || "malformed string".to_string());
212
+ return false;
213
+ };
214
+ match &self.str {
215
+ Some(_) => self.validate_string(inner, base, ctx),
216
+ None => true,
217
+ }
218
+ }
219
+ b't' => {
220
+ if !c.eat_token(b"true") {
221
+ ctx.record(base, "parse", || "malformed literal".to_string());
222
+ return false;
223
+ }
224
+ true
225
+ }
226
+ b'f' => {
227
+ if !c.eat_token(b"false") {
228
+ ctx.record(base, "parse", || "malformed literal".to_string());
229
+ return false;
230
+ }
231
+ true
232
+ }
233
+ b'n' => {
234
+ if !c.eat_token(b"null") {
235
+ ctx.record(base, "parse", || "malformed literal".to_string());
236
+ return false;
237
+ }
238
+ true
239
+ }
240
+ _ => {
241
+ let Some((n, _is_int)) = c.number() else {
242
+ ctx.record(base, "parse", || "malformed number".to_string());
243
+ return false;
244
+ };
245
+ // `type: "integer"` (without `number`) rejects non-integral values.
246
+ if self.types & T_INT != 0 && self.types & T_NUM == 0 && n.fract() != 0.0 {
247
+ ctx.record(base, "type", || "expected an integer".to_string());
248
+ return false;
249
+ }
250
+ match &self.num {
251
+ Some(num) => {
252
+ if let Some(min) = num.minimum {
253
+ if n < min {
254
+ ctx.record(base, "minimum", || format!("number must be >= {min}"));
255
+ return false;
256
+ }
257
+ }
258
+ if let Some(max) = num.maximum {
259
+ if n > max {
260
+ ctx.record(base, "maximum", || format!("number must be <= {max}"));
261
+ return false;
262
+ }
263
+ }
264
+ if let Some(e) = num.exclusive_min {
265
+ if n <= e {
266
+ ctx.record(base, "exclusiveMinimum", || {
267
+ format!("number must be > {e}")
268
+ });
269
+ return false;
270
+ }
271
+ }
272
+ if let Some(e) = num.exclusive_max {
273
+ if n >= e {
274
+ ctx.record(base, "exclusiveMaximum", || {
275
+ format!("number must be < {e}")
276
+ });
277
+ return false;
278
+ }
279
+ }
280
+ if let Some(m) = num.multiple_of {
281
+ if !is_multiple_of(n, m) {
282
+ ctx.record(base, "multipleOf", || {
283
+ format!("number must be a multiple of {m}")
284
+ });
285
+ return false;
286
+ }
287
+ }
288
+ true
289
+ }
290
+ None => true,
291
+ }
292
+ }
293
+ }
294
+ }
295
+
296
+ fn validate_string(&self, inner: &[u8], base: &str, ctx: &mut Ctx) -> bool {
297
+ let Some(s) = &self.str else {
298
+ return true;
299
+ };
300
+ let len = count_chars(inner);
301
+ if let Some(min) = s.min_len {
302
+ if len < min {
303
+ ctx.record(base, "minLength", || {
304
+ format!("string is shorter than {min} characters")
305
+ });
306
+ return false;
307
+ }
308
+ }
309
+ if let Some(max) = s.max_len {
310
+ if len > max {
311
+ ctx.record(base, "maxLength", || {
312
+ format!("string is longer than {max} characters")
313
+ });
314
+ return false;
315
+ }
316
+ }
317
+ if let Some(re) = &s.pattern {
318
+ // Pattern matches the DECODED string value (like the reference).
319
+ let owned;
320
+ let content: &[u8] = if inner.contains(&b'\\') {
321
+ owned = decode_string(inner);
322
+ &owned
323
+ } else {
324
+ inner
325
+ };
326
+ let text = match std::str::from_utf8(content) {
327
+ Ok(t) => t,
328
+ Err(_) => {
329
+ ctx.record(base, "pattern", || "string is not valid UTF-8".to_string());
330
+ return false;
331
+ }
332
+ };
333
+ if !re.is_match(text).unwrap_or(false) {
334
+ ctx.record(base, "pattern", || {
335
+ "string does not match the required pattern".to_string()
336
+ });
337
+ return false;
338
+ }
339
+ }
340
+ if s.format_email {
341
+ let owned;
342
+ let content: &[u8] = if inner.contains(&b'\\') {
343
+ owned = decode_string(inner);
344
+ &owned
345
+ } else {
346
+ inner
347
+ };
348
+ if !super::email::is_valid_email_format(content) {
349
+ ctx.record(base, "format", || {
350
+ "string is not a valid email address".to_string()
351
+ });
352
+ return false;
353
+ }
354
+ }
355
+ true
356
+ }
357
+
358
+ /// Run allOf / anyOf / oneOf / not / if-then-else over the value's raw bytes.
359
+ fn validate_comb_raw(&self, raw: &[u8], ctx: &mut Ctx) -> bool {
360
+ let Some(comb) = &self.comb else {
361
+ return true;
362
+ };
363
+ let base = self.schema_path.as_deref().unwrap_or("");
364
+ // allOf: a failing branch IS the cause — run with the real ctx.
365
+ for sub in &comb.all_of {
366
+ let mut c = Cursor::new(raw);
367
+ if !sub.validate(&mut c, ctx) {
368
+ return false;
369
+ }
370
+ }
371
+ // anyOf / oneOf / not / if-guard: a branch's internal outcome is not
372
+ // itself the cause, so suppress its error recording.
373
+ if !comb.any_of.is_empty() {
374
+ let mut any_ok = false;
375
+ for sub in &comb.any_of {
376
+ let mut c = Cursor::new(raw);
377
+ if ctx.with_suppressed(|ctx| sub.validate(&mut c, ctx)) {
378
+ any_ok = true;
379
+ break;
380
+ }
381
+ }
382
+ if !any_ok {
383
+ ctx.record(base, "anyOf", || {
384
+ "value does not match any of the allowed schemas".to_string()
385
+ });
386
+ return false;
387
+ }
388
+ }
389
+ if !comb.one_of.is_empty() {
390
+ let mut count = 0usize;
391
+ for sub in &comb.one_of {
392
+ let mut c = Cursor::new(raw);
393
+ if ctx.with_suppressed(|ctx| sub.validate(&mut c, ctx)) {
394
+ count += 1;
395
+ }
396
+ }
397
+ if count != 1 {
398
+ ctx.record(base, "oneOf", || {
399
+ if count == 0 {
400
+ "value matches none of the required schemas".to_string()
401
+ } else {
402
+ "value matches more than one of the required schemas".to_string()
403
+ }
404
+ });
405
+ return false;
406
+ }
407
+ }
408
+ if let Some(sub) = &comb.not {
409
+ let mut c = Cursor::new(raw);
410
+ if ctx.with_suppressed(|ctx| sub.validate(&mut c, ctx)) {
411
+ ctx.record(base, "not", || {
412
+ "value matches the forbidden schema".to_string()
413
+ });
414
+ return false;
415
+ }
416
+ }
417
+ if let Some((if_node, then_node, else_node)) = &comb.if_then_else {
418
+ let mut c = Cursor::new(raw);
419
+ let if_ok = ctx.with_suppressed(|ctx| if_node.validate(&mut c, ctx));
420
+ if if_ok {
421
+ if let Some(t) = then_node {
422
+ let mut c = Cursor::new(raw);
423
+ if !t.validate(&mut c, ctx) {
424
+ return false;
425
+ }
426
+ }
427
+ } else if let Some(e) = else_node {
428
+ let mut c = Cursor::new(raw);
429
+ if !e.validate(&mut c, ctx) {
430
+ return false;
431
+ }
432
+ }
433
+ }
434
+ true
435
+ }
436
+ }
437
+
438
+ fn kind_name(k: Kind) -> &'static str {
439
+ match k {
440
+ Kind::Object => "object",
441
+ Kind::Array => "array",
442
+ Kind::String => "string",
443
+ Kind::Number => "number",
444
+ Kind::Boolean => "boolean",
445
+ Kind::Null => "null",
446
+ }
447
+ }
448
+
449
+ fn type_names(mask: u8) -> String {
450
+ let mut names = Vec::new();
451
+ for (bit, name) in [
452
+ (T_NULL, "null"),
453
+ (T_BOOL, "boolean"),
454
+ (T_NUM, "number"),
455
+ (T_INT, "integer"),
456
+ (T_STR, "string"),
457
+ (T_ARR, "array"),
458
+ (T_OBJ, "object"),
459
+ ] {
460
+ if mask & bit != 0 {
461
+ names.push(name);
462
+ }
463
+ }
464
+ if names.is_empty() {
465
+ "any value".to_string()
466
+ } else {
467
+ names.join(" or ")
468
+ }
469
+ }
470
+
471
+ /// `multipleOf` exactness, replicating jsonschema's `is_multiple_of_float`:
472
+ /// exact rational division via fraction::BigFraction, denominator must be 1.
473
+ fn is_multiple_of(value: f64, multiple: f64) -> bool {
474
+ // `fraction::BigFraction::from(f64)` cannot represent non-finite values
475
+ // (`1e999` parses to `f64::INFINITY` in `Cursor::number`) and PANICS on
476
+ // them. Via the raw C-ABI (bun:ffi) path a panic is a whole-process crash
477
+ // (no catch_unwind equivalent), so guard up front and report "not a
478
+ // multiple" — consistent with the DOM validator, which also yields
479
+ // schema-invalid for such a body (its serde parse of `1e999` fails).
480
+ if !value.is_finite() || !multiple.is_finite() {
481
+ return false;
482
+ }
483
+ if value == 0.0 {
484
+ return true;
485
+ }
486
+ if value.abs() < multiple {
487
+ return false;
488
+ }
489
+ let r = fraction::BigFraction::from(value) / fraction::BigFraction::from(multiple);
490
+ r.denom().is_none_or(One::is_one)
491
+ }
492
+
493
+ /// Exact distinct-key tracking for `minProperties`/`maxProperties`, WITHOUT a
494
+ /// per-document heap allocation in the common case.
495
+ ///
496
+ /// The common case (objects with a modest number of short keys) is tracked in
497
+ /// a fixed stack array; only pathological objects (many distinct keys or long
498
+ /// keys) migrate to a heap `Vec`. Keeps the otherwise zero-alloc schema fast
499
+ /// path heap-light even when the schema uses these keywords.
500
+ const INLINE_DISTINCT: usize = 16;
501
+ const INLINE_KEY_BYTES: usize = 48;
502
+
503
+ // The Inline variant is intentionally much larger than Heap (a stack-resident
504
+ // array for the common case); the enum lives on the stack, so this is fine.
505
+ #[allow(clippy::large_enum_variant)]
506
+ pub(crate) enum DistinctKeys {
507
+ Inline {
508
+ keys: [([u8; INLINE_KEY_BYTES], usize); INLINE_DISTINCT],
509
+ len: usize,
510
+ },
511
+ Heap(Vec<Box<[u8]>>),
512
+ }
513
+
514
+ impl DistinctKeys {
515
+ fn new() -> Self {
516
+ DistinctKeys::Inline {
517
+ keys: [([0u8; INLINE_KEY_BYTES], 0); INLINE_DISTINCT],
518
+ len: 0,
519
+ }
520
+ }
521
+
522
+ /// Record `key` as seen (exact byte comparison); returns whether it was new.
523
+ fn insert(&mut self, key: &[u8]) -> bool {
524
+ match self {
525
+ DistinctKeys::Inline { keys, len } => {
526
+ for (k, klen) in keys[..*len].iter() {
527
+ if *klen == key.len() && &k[..*klen] == key {
528
+ return false;
529
+ }
530
+ }
531
+ if *len < INLINE_DISTINCT && key.len() <= INLINE_KEY_BYTES {
532
+ let (k, klen) = &mut keys[*len];
533
+ k[..key.len()].copy_from_slice(key);
534
+ *klen = key.len();
535
+ *len += 1;
536
+ return true;
537
+ }
538
+ // Inline capacity exhausted: migrate to the heap tracker.
539
+ let mut v = Vec::with_capacity(*len + 1);
540
+ for (k, klen) in keys[..*len].iter() {
541
+ v.push(k[..*klen].to_vec().into_boxed_slice());
542
+ }
543
+ v.push(key.to_vec().into_boxed_slice());
544
+ *self = DistinctKeys::Heap(v);
545
+ true
546
+ }
547
+ DistinctKeys::Heap(v) => {
548
+ if v.iter().any(|k| k.as_ref() == key) {
549
+ return false;
550
+ }
551
+ v.push(key.to_vec().into_boxed_slice());
552
+ true
553
+ }
554
+ }
555
+ }
556
+
557
+ fn len(&self) -> usize {
558
+ match self {
559
+ DistinctKeys::Inline { len, .. } => *len,
560
+ DistinctKeys::Heap(v) => v.len(),
561
+ }
562
+ }
563
+ }
564
+
565
+ pub(crate) fn validate_object(o: &FastObject, c: &mut Cursor, ctx: &mut Ctx, base: &str) -> bool {
566
+ if !c.eat(b'{') {
567
+ return false;
568
+ }
569
+ c.skip_ws();
570
+ let need_distinct = o.min_props.is_some() || o.max_props.is_some();
571
+ let need_present = !o.dependent_required.is_empty();
572
+ let mut count: usize = 0;
573
+ let mut seen_required: u64 = 0;
574
+ let mut obj_ok = true;
575
+ let mut distinct: Option<DistinctKeys> = if need_distinct {
576
+ Some(DistinctKeys::new())
577
+ } else {
578
+ None
579
+ };
580
+ let mut present: Option<Vec<Box<[u8]>>> = if need_present { Some(Vec::new()) } else { None };
581
+
582
+ if c.eat(b'}') {
583
+ return finish_object(o, count, seen_required, &distinct, &present, ctx, base) && obj_ok;
584
+ }
585
+ loop {
586
+ c.skip_ws();
587
+ let Some(raw) = c.raw_string() else {
588
+ return false;
589
+ };
590
+ let owned;
591
+ let key: &[u8] = if raw.contains(&b'\\') {
592
+ owned = decode_string(raw);
593
+ &owned
594
+ } else {
595
+ raw
596
+ };
597
+ count += 1;
598
+ if let Some(d) = &mut distinct {
599
+ d.insert(key);
600
+ }
601
+ if let Some(p) = &mut present {
602
+ p.push(key.to_vec().into_boxed_slice());
603
+ }
604
+ // `required` lookup guarded on non-empty so a schema with no `required`
605
+ // keyword skips hashing every member key (exact: an empty map's `get`
606
+ // is always None). `seen_required` stays 0 either way.
607
+ if !o.required.is_empty() {
608
+ if let Some(&bit) = o.required.get(key) {
609
+ seen_required |= 1u64 << bit;
610
+ }
611
+ }
612
+ c.skip_ws();
613
+ if !c.eat(b':') {
614
+ return false;
615
+ }
616
+ // Align to the value's first byte (whitespace after the colon is not
617
+ // part of the value). `validate_inner` also skips ws at its start, so
618
+ // this is a no-op for validation; it makes `vstart` (used for one-pass
619
+ // derive capture + error realignment) point at the actual value.
620
+ c.skip_ws();
621
+ // Schemas applying to this key: the `properties` schema (if any) plus
622
+ // every matching `patternProperties` schema — ALL apply conjunctively
623
+ // (a key covered by either is not an "additional" property). `props`
624
+ // lookup borrows (no Arc clone per member); pattern matching runs the
625
+ // regexes once per applicable key, inlined (no per-member Vec). The
626
+ // lookup is guarded on non-empty so a bare `{ "type": "object" }`
627
+ // schema (no `properties`) skips hashing every member key (exact: an
628
+ // empty map's `get` is always None → falls to the `additional` branch).
629
+ let prop_node = if o.props.is_empty() {
630
+ None
631
+ } else {
632
+ o.props.get(key)
633
+ };
634
+ let vstart = c.pos;
635
+ ctx.capture_enter_key(c.data.as_ptr(), key);
636
+ ctx.push_key(key);
637
+ let ok = if o.patterns.is_empty() {
638
+ match prop_node {
639
+ Some(node) => node.validate(c, ctx),
640
+ None => match &o.additional {
641
+ Additional::Allow => c.skip_value(ctx.depth),
642
+ Additional::Deny => {
643
+ ctx.record(base, "additionalProperties", || {
644
+ "additional properties are not allowed".to_string()
645
+ });
646
+ false
647
+ }
648
+ Additional::Schema(s) => s.validate(c, ctx),
649
+ },
650
+ }
651
+ } else {
652
+ // property schema + every matching pattern schema (conjunctively).
653
+ // No per-member heap Vec / Arc clones: capture the value span once,
654
+ // then re-scan each applicable schema on a fresh cursor.
655
+ let data = c.data;
656
+ if !c.skip_value(ctx.depth) {
657
+ false
658
+ } else {
659
+ let raw = &data[vstart..c.pos];
660
+ let mut all_ok = true;
661
+ if let Some(p) = prop_node {
662
+ let mut cc = Cursor::new(raw);
663
+ all_ok = p.validate(&mut cc, ctx);
664
+ }
665
+ let mut matched_pattern = false;
666
+ for (re, n) in &o.patterns {
667
+ if !all_ok {
668
+ break;
669
+ }
670
+ if std::str::from_utf8(key).is_ok_and(|k| re.is_match(k).unwrap_or(false)) {
671
+ matched_pattern = true;
672
+ let mut cc = Cursor::new(raw);
673
+ all_ok = n.validate(&mut cc, ctx);
674
+ }
675
+ }
676
+ if all_ok && !matched_pattern && prop_node.is_none() {
677
+ // No schema covers this key → apply additionalProperties.
678
+ match &o.additional {
679
+ Additional::Allow => {}
680
+ Additional::Deny => {
681
+ ctx.record(base, "additionalProperties", || {
682
+ "additional properties are not allowed".to_string()
683
+ });
684
+ all_ok = false;
685
+ }
686
+ Additional::Schema(s) => {
687
+ let mut cc = Cursor::new(raw);
688
+ all_ok = s.validate(&mut cc, ctx);
689
+ }
690
+ }
691
+ }
692
+ all_ok
693
+ }
694
+ };
695
+ // One-pass derive capture: this member's value spans [vstart, c.pos).
696
+ // Entered above (trie descend), recorded here, exited here — all on
697
+ // the ROOT walk only (sub-scan cursors are skipped via the pointer).
698
+ ctx.capture_record_value(c.data.as_ptr(), vstart, c.pos);
699
+ ctx.capture_exit_key(c.data.as_ptr());
700
+ ctx.pop();
701
+ if !ok {
702
+ if ctx.is_detailed() {
703
+ // Collect every failing property: realign past this value and
704
+ // continue validating the remaining members.
705
+ obj_ok = false;
706
+ realign(c, vstart, ctx.depth);
707
+ } else {
708
+ return false;
709
+ }
710
+ }
711
+ c.skip_ws();
712
+ if c.eat(b'}') {
713
+ let fin = finish_object(o, count, seen_required, &distinct, &present, ctx, base);
714
+ return obj_ok && fin;
715
+ }
716
+ if !c.eat(b',') {
717
+ return false;
718
+ }
719
+ }
720
+ }
721
+
722
+ pub(crate) fn finish_object(
723
+ o: &FastObject,
724
+ count: usize,
725
+ seen_required: u64,
726
+ distinct: &Option<DistinctKeys>,
727
+ present: &Option<Vec<Box<[u8]>>>,
728
+ ctx: &mut Ctx,
729
+ base: &str,
730
+ ) -> bool {
731
+ let total = distinct.as_ref().map(DistinctKeys::len).unwrap_or(count);
732
+ if let Some(min) = o.min_props {
733
+ if total < min {
734
+ ctx.record(base, "minProperties", || {
735
+ format!("object must have at least {min} properties")
736
+ });
737
+ return false;
738
+ }
739
+ }
740
+ if let Some(max) = o.max_props {
741
+ if total > max {
742
+ ctx.record(base, "maxProperties", || {
743
+ format!("object must have at most {max} properties")
744
+ });
745
+ return false;
746
+ }
747
+ }
748
+ if o.required_count > 0 {
749
+ let full = if o.required_count == 64 {
750
+ u64::MAX
751
+ } else {
752
+ (1u64 << o.required_count) - 1
753
+ };
754
+ if seen_required != full {
755
+ ctx.record(base, "required", || {
756
+ let missing: Vec<String> = o
757
+ .required
758
+ .iter()
759
+ .filter(|(_, &bit)| seen_required & (1u64 << bit) == 0)
760
+ .map(|(k, _)| String::from_utf8_lossy(k).into_owned())
761
+ .collect();
762
+ format!("missing required properties: {}", missing.join(", "))
763
+ });
764
+ return false;
765
+ }
766
+ }
767
+ if let Some(present) = present {
768
+ for (prop, reqs) in &o.dependent_required {
769
+ if present.iter().any(|k| k.as_ref() == prop.as_ref()) {
770
+ for r in reqs {
771
+ if !present.iter().any(|k| k.as_ref() == r.as_ref()) {
772
+ ctx.record(base, "dependencies", || {
773
+ format!(
774
+ "property \"{}\" requires property \"{}\"",
775
+ String::from_utf8_lossy(prop),
776
+ String::from_utf8_lossy(r)
777
+ )
778
+ });
779
+ return false;
780
+ }
781
+ }
782
+ }
783
+ }
784
+ }
785
+ true
786
+ }
787
+
788
+ pub(crate) fn validate_array(a: &FastArray, c: &mut Cursor, ctx: &mut Ctx, base: &str) -> bool {
789
+ if !c.eat(b'[') {
790
+ return false;
791
+ }
792
+ c.skip_ws();
793
+ let mut count = 0usize;
794
+ let mut arr_ok = true;
795
+ let mut unique_seen: Option<Vec<sonic_rs::Value>> =
796
+ if a.unique { Some(Vec::new()) } else { None };
797
+ let mut contains_found = a.contains.is_none();
798
+ if c.eat(b']') {
799
+ return check_array_counts(a, count, ctx, base)
800
+ && contains_ok(a, contains_found, base, ctx)
801
+ && arr_ok;
802
+ }
803
+ loop {
804
+ count += 1;
805
+ if let Some(max) = a.max_items {
806
+ if count > max {
807
+ ctx.record(base, "maxItems", || {
808
+ format!("array has more than {max} items")
809
+ });
810
+ return false;
811
+ }
812
+ }
813
+ let idx = count - 1;
814
+ let elem_start = c.pos;
815
+ let ok = match &a.tuple_items {
816
+ Some(tuple) if idx < tuple.len() => validate_element(&tuple[idx], c, ctx, idx),
817
+ Some(_) => match &a.additional_items {
818
+ Some(Additional::Allow) | None => c.skip_value(ctx.depth),
819
+ Some(Additional::Deny) => {
820
+ ctx.push_idx(idx);
821
+ ctx.record(base, "additionalItems", || {
822
+ "additional array items are not allowed".to_string()
823
+ });
824
+ ctx.pop();
825
+ false
826
+ }
827
+ Some(Additional::Schema(s)) => validate_element(s, c, ctx, idx),
828
+ },
829
+ None => match &a.items {
830
+ Some(node) => validate_element(node, c, ctx, idx),
831
+ None => c.skip_value(ctx.depth),
832
+ },
833
+ };
834
+ if !ok {
835
+ if ctx.is_detailed() {
836
+ arr_ok = false;
837
+ realign(c, elem_start, ctx.depth);
838
+ } else {
839
+ return false;
840
+ }
841
+ }
842
+ // uniqueItems: compare parsed element values (matches the reference's
843
+ // `is_unique`, incl. `1 == 1.0` and insertion-order object comparison).
844
+ if let Some(seen) = &mut unique_seen {
845
+ let data = c.data;
846
+ let raw = &data[elem_start..c.pos];
847
+ if let Ok(val) = sonic_rs::from_slice::<sonic_rs::Value>(raw) {
848
+ if seen.iter().any(|v| sonic_values_equal(v, &val)) {
849
+ ctx.record(base, "uniqueItems", || {
850
+ "array items must be unique".to_string()
851
+ });
852
+ arr_ok = false;
853
+ } else {
854
+ seen.push(val);
855
+ }
856
+ }
857
+ }
858
+ // contains: does this element validate the contains schema?
859
+ if !contains_found {
860
+ if let Some(con) = &a.contains {
861
+ let data = c.data;
862
+ let raw = &data[elem_start..c.pos];
863
+ let mut cc = Cursor::new(raw);
864
+ if ctx.with_suppressed(|ctx| con.validate(&mut cc, ctx)) {
865
+ contains_found = true;
866
+ }
867
+ }
868
+ }
869
+ c.skip_ws();
870
+ if c.eat(b']') {
871
+ let fin = check_array_counts(a, count, ctx, base)
872
+ && contains_ok(a, contains_found, base, ctx);
873
+ // One-pass derive capture: this array's element count (only on the
874
+ // ROOT walk; sub-scan cursors are skipped via the data-pointer).
875
+ ctx.capture_record_length(c.data.as_ptr(), count);
876
+ return arr_ok && fin;
877
+ }
878
+ if !c.eat(b',') {
879
+ return false;
880
+ }
881
+ c.skip_ws();
882
+ }
883
+ }
884
+
885
+ /// In detailed mode, skip the remainder of a failed value so validation can
886
+ /// continue to the next property/element (collecting every error). No-op if the
887
+ /// value is malformed (can't safely resync).
888
+ fn realign(c: &mut Cursor, vstart: usize, depth: u32) {
889
+ let data = c.data;
890
+ let mut sc = Cursor::new(&data[vstart..]);
891
+ if sc.skip_value(depth) {
892
+ c.pos = vstart + sc.pos;
893
+ }
894
+ }
895
+
896
+ fn validate_element(node: &FastNode, c: &mut Cursor, ctx: &mut Ctx, idx: usize) -> bool {
897
+ ctx.push_idx(idx);
898
+ let ok = node.validate(c, ctx);
899
+ ctx.pop();
900
+ ok
901
+ }
902
+
903
+ fn contains_ok(a: &FastArray, contains_found: bool, base: &str, ctx: &mut Ctx) -> bool {
904
+ if a.contains.is_some() && !contains_found {
905
+ ctx.record(base, "contains", || {
906
+ "array does not contain a matching item".to_string()
907
+ });
908
+ return false;
909
+ }
910
+ true
911
+ }
912
+
913
+ pub(crate) fn check_array_counts(a: &FastArray, count: usize, ctx: &mut Ctx, base: &str) -> bool {
914
+ if let Some(min) = a.min_items {
915
+ if count < min {
916
+ ctx.record(base, "minItems", || {
917
+ format!("array must have at least {min} items")
918
+ });
919
+ return false;
920
+ }
921
+ }
922
+ if let Some(max) = a.max_items {
923
+ if count > max {
924
+ ctx.record(base, "maxItems", || {
925
+ format!("array must have at most {max} items")
926
+ });
927
+ return false;
928
+ }
929
+ }
930
+ true
931
+ }