@crewhaus/tool-schema 0.7.0 → 0.8.0

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.
@@ -1,3 +1,4 @@
1
+ import { checkFormat, isFormatName } from "./formats";
1
2
  /**
2
3
  * A JSON Schema Draft-07 validator, written out in full here so the package
3
4
  * has no runtime dependency and so its behaviour is auditable in one file.
@@ -49,11 +50,167 @@
49
50
  * independently of the type.
50
51
  * - Errors stop accumulating at `maxErrors`, and the result says `truncated`.
51
52
  *
53
+ * ## A bounded amount of work, and "undetermined" when it runs out
54
+ *
55
+ * `anyOf`/`oneOf`/`allOf` over `$ref`s that share a target multiply: a
56
+ * 1.3 KB schema can ask for 2^30 subschema evaluations, and the walk is
57
+ * synchronous. Every subschema evaluation is counted against a budget. So
58
+ * is every check whose cost follows the value's size rather than the
59
+ * schema's — a string's length or pattern, uniqueItems, an object's keys,
60
+ * each enum candidate and each (key, patternProperties pattern) test, and
61
+ * each error message built — so a small schema cannot spend the value's
62
+ * size once per leaf. When the budget runs out the walk stops and the
63
+ * result is `undetermined`, with `valid: false` and the reason — never a
64
+ * verdict either way. A failing branch is summarised in a bounded message,
65
+ * and a message is built only when it will be kept.
66
+ *
67
+ * The default budget ({@link defaultWorkLimit}) follows what an honest walk
68
+ * can cost. A schema written out as a tree (no `$ref` reached twice at one
69
+ * place) evaluates each of its subschemas at most once per value node, so a
70
+ * value node is allowed as many units as the schema has subschemas (at
71
+ * least {@link WORK_PER_VALUE_NODE}, at most {@link MAX_SCHEMA_FANOUT}); a
72
+ * wide or nested union over thousands of valid rows is answered. Only
73
+ * repeating a `$ref` at one place goes past that, which is the shape that
74
+ * multiplies. The floor is {@link DEFAULT_MAX_WORK} and the ceiling
75
+ * {@link MAX_DEFAULT_WORK}, a few seconds of the worst schemas measured.
76
+ *
77
+ * ## Depth is the value's
78
+ *
79
+ * A recursive schema walks as deep as the value does, so nesting is counted
80
+ * where the value nests — into an item or a property — not per `$ref` or
81
+ * branch. A value deeper than {@link MAX_VALUE_DEPTH}, or a schema and value
82
+ * nesting past {@link MAX_NESTING} walk frames together, is `undetermined`:
83
+ * the part not walked may be valid or not. A `$ref` that reaches itself at
84
+ * one position with nothing consumed between is a cycle, and an error.
85
+ *
52
86
  * `pattern` is an unanchored ECMA-262 match, per the spec — `"pattern": "a"`
53
87
  * matches `"banana"`.
54
88
  */
55
- import { checkFormat, isFormatName } from "./formats";
56
- import { canonicalize, deepEqual, isPlainObject, joinPointer, matchesType, parsePointer, preview, resolveSegments, typeOf, } from "./value";
89
+ import { runPatternSync, screenCallerPattern, } from "./regex-answers";
90
+ import { canonicalize, isPlainObject, joinPointer, matchesType, parsePointer, preview as previewValue, resolveSegments, typeOf, } from "./value";
91
+ /** The budget floor: about a third of a second of the worst schemas measured (Apple silicon). */
92
+ export const DEFAULT_MAX_WORK = 500_000;
93
+ /**
94
+ * The least work allowed per unit of the value's weight, so a large document
95
+ * is not refused for its size. A schema with more subschemas than this is
96
+ * allowed its subschema count instead.
97
+ */
98
+ export const WORK_PER_VALUE_NODE = 64;
99
+ /** The most work per unit of weight a schema's size earns. */
100
+ export const MAX_SCHEMA_FANOUT = 1_024;
101
+ /**
102
+ * The most work a default budget allows, however large the value: about
103
+ * seven seconds of the worst schemas measured (Apple silicon, 0.3-0.5 us a
104
+ * unit). The walk is synchronous, so this is how long one call can hold the
105
+ * harness's thread.
106
+ */
107
+ export const MAX_DEFAULT_WORK = 20_000_000;
108
+ /** Characters of a string (or key) that weigh, and cost, one unit. */
109
+ const CHARS_PER_UNIT = 256;
110
+ /** Keys or items that cost one unit to list or compare. */
111
+ const MEMBERS_PER_UNIT = 16;
112
+ /** Nodes in a JSON value (itself included), counted iteratively, stopping at `cap`. */
113
+ export function countValueNodes(value, cap = Number.MAX_SAFE_INTEGER) {
114
+ let count = 0;
115
+ const stack = [value];
116
+ while (stack.length > 0 && count < cap) {
117
+ const node = stack.pop();
118
+ count++;
119
+ if (Array.isArray(node))
120
+ for (const item of node)
121
+ stack.push(item);
122
+ else if (isPlainObject(node))
123
+ for (const key of Object.keys(node))
124
+ stack.push(node[key]);
125
+ }
126
+ return count;
127
+ }
128
+ /**
129
+ * A value's weight: one per node, and one more per CHARS_PER_UNIT
130
+ * characters of each string and key — the size the size-bound checks are
131
+ * charged in, so the budget grows with the value in the same units.
132
+ */
133
+ export function valueWeight(value) {
134
+ let weight = 0;
135
+ const stack = [value];
136
+ while (stack.length > 0) {
137
+ const node = stack.pop();
138
+ weight += 1;
139
+ if (typeof node === "string")
140
+ weight += Math.floor(node.length / CHARS_PER_UNIT);
141
+ else if (Array.isArray(node))
142
+ for (const item of node)
143
+ stack.push(item);
144
+ else if (isPlainObject(node)) {
145
+ for (const key of Object.keys(node)) {
146
+ weight += Math.floor(key.length / CHARS_PER_UNIT);
147
+ stack.push(node[key]);
148
+ }
149
+ }
150
+ }
151
+ return weight;
152
+ }
153
+ /**
154
+ * Subschemas in `schema` written out as a tree, `$ref`s not followed, up to
155
+ * `cap`: what one value node can honestly be checked against. Counted
156
+ * iteratively, since a schema may nest deeper than the stack.
157
+ */
158
+ export function schemaFanout(schema, cap = MAX_SCHEMA_FANOUT) {
159
+ let count = 0;
160
+ const stack = [schema];
161
+ while (stack.length > 0 && count < cap) {
162
+ const node = stack.pop();
163
+ if (typeof node === "boolean") {
164
+ count += 1;
165
+ continue;
166
+ }
167
+ if (!isPlainObject(node))
168
+ continue;
169
+ count += 1;
170
+ for (const keyword of ["properties", "patternProperties", "$defs", "definitions"]) {
171
+ const map = node[keyword];
172
+ if (isPlainObject(map))
173
+ for (const key of Object.keys(map))
174
+ stack.push(map[key]);
175
+ }
176
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
177
+ const list = node[keyword];
178
+ if (Array.isArray(list))
179
+ for (const branch of list)
180
+ stack.push(branch);
181
+ }
182
+ const items = node["items"];
183
+ if (Array.isArray(items))
184
+ for (const item of items)
185
+ stack.push(item);
186
+ else if (items !== undefined)
187
+ stack.push(items);
188
+ for (const keyword of [
189
+ "additionalItems",
190
+ "additionalProperties",
191
+ "contains",
192
+ "propertyNames",
193
+ "not",
194
+ "if",
195
+ "then",
196
+ "else",
197
+ ]) {
198
+ if (node[keyword] !== undefined)
199
+ stack.push(node[keyword]);
200
+ }
201
+ }
202
+ return Math.min(count, cap);
203
+ }
204
+ /**
205
+ * The default budget for validating `value` against `schema`: the value's
206
+ * weight times what the schema allows per unit of it, between
207
+ * DEFAULT_MAX_WORK and MAX_DEFAULT_WORK.
208
+ */
209
+ export function defaultWorkLimit(value, schema) {
210
+ const perUnit = Math.max(WORK_PER_VALUE_NODE, schema === undefined ? 0 : schemaFanout(schema, MAX_SCHEMA_FANOUT));
211
+ const weighted = valueWeight(value) * perUnit;
212
+ return Math.max(DEFAULT_MAX_WORK, Math.min(weighted, MAX_DEFAULT_WORK));
213
+ }
57
214
  /** Keywords that constrain a value and are implemented here. */
58
215
  const SUPPORTED = new Set([
59
216
  "type",
@@ -107,30 +264,214 @@ const ANNOTATIONS = new Set([
107
264
  "$defs",
108
265
  "definitions",
109
266
  ]);
267
+ /** Deepest value the walk follows; past it the answer is undetermined. */
268
+ export const MAX_VALUE_DEPTH = 512;
269
+ /**
270
+ * Most walk frames (a subschema evaluation inside another) at once, value
271
+ * and schema nesting together; past it the answer is undetermined. It keeps
272
+ * a deeply nested schema from overflowing the stack.
273
+ */
274
+ export const MAX_NESTING = 2_048;
275
+ /** How deep `checkSchemaShape` descends; it only guards a hand-built cyclic object. */
276
+ const MAX_SHAPE_DEPTH = 100;
277
+ /** Thrown when the work budget runs out; caught only in `validateValue`. */
278
+ class WorkExhausted extends Error {
279
+ }
280
+ /** Thrown when the walk cannot reach a verdict for another reason; caught only in `validateValue`. */
281
+ class Undetermined extends Error {
282
+ }
283
+ /** Where in the whole value `path` is, for a message. */
284
+ function where(ctx, path) {
285
+ const absolute = `${ctx.refBase}${path}`;
286
+ return absolute === "" ? "the top" : clip(absolute, 120);
287
+ }
288
+ /** Spend `units` of the budget, stopping the walk once it is gone. */
289
+ function charge(ctx, units) {
290
+ ctx.work.used += units;
291
+ if (ctx.work.used > ctx.work.limit)
292
+ throw new WorkExhausted();
293
+ }
294
+ /** A bounded preview, reusing the walk's renderings of the value's objects. */
295
+ function preview(ctx, value, maxChars) {
296
+ return previewValue(value, maxChars, ctx.previews);
297
+ }
298
+ /** Longest message one error carries; nested branch reasons are cut here. */
299
+ export const MAX_MESSAGE_CHARS = 1_000;
300
+ /** The shortest a branch's reason is cut to before later branches are dropped instead. */
301
+ const MIN_REASON_CHARS = 48;
302
+ function clip(text, max) {
303
+ return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
304
+ }
110
305
  /**
111
- * Depth ceiling for the walk. It guards a schema that references itself
112
- * through a value that never shrinks, and it also stops a value nested past
113
- * this many levels — both are counted, so the message names either cause.
306
+ * Record a failure. The message is built only when it will be kept: past
307
+ * `maxErrors` a failure costs nothing to report, and one that is kept is
308
+ * charged for the text it builds.
114
309
  */
115
- const MAX_DEPTH = 100;
116
310
  function fail(ctx, path, keyword, schemaPath, message) {
117
311
  if (ctx.errors.length >= ctx.opts.maxErrors) {
118
312
  ctx.truncated = true;
119
313
  return false;
120
314
  }
121
- ctx.errors.push({ path, keyword, message, schemaPath });
315
+ const text = clip(typeof message === "string" ? message : message(), MAX_MESSAGE_CHARS);
316
+ charge(ctx, 1 + Math.floor(text.length / 512));
317
+ ctx.errors.push({ path, keyword, message: text, schemaPath });
318
+ return false;
319
+ }
320
+ /** Code points in `text`, counted without building an array of them. */
321
+ function codePointLength(text) {
322
+ let length = text.length;
323
+ for (let i = 0; i < text.length - 1; i++) {
324
+ const unit = text.charCodeAt(i);
325
+ if (unit >= 0xd800 && unit <= 0xdbff) {
326
+ const next = text.charCodeAt(i + 1);
327
+ if (next >= 0xdc00 && next <= 0xdfff) {
328
+ length -= 1;
329
+ i += 1;
330
+ }
331
+ }
332
+ }
333
+ return length;
334
+ }
335
+ /**
336
+ * `deepEqual`, charged for what it reads: listing an object's keys and
337
+ * comparing long strings cost the value's size, not the schema's.
338
+ */
339
+ function sameJson(a, b, ctx) {
340
+ if (typeof a === "string" && typeof b === "string") {
341
+ charge(ctx, Math.floor(Math.min(a.length, b.length) / CHARS_PER_UNIT));
342
+ return a === b;
343
+ }
344
+ if (a === b)
345
+ return true;
346
+ if (typeof a !== typeof b)
347
+ return false;
348
+ if (Array.isArray(a) || Array.isArray(b)) {
349
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length)
350
+ return false;
351
+ charge(ctx, Math.floor(a.length / MEMBERS_PER_UNIT));
352
+ for (let i = 0; i < a.length; i++)
353
+ if (!sameJson(a[i], b[i], ctx))
354
+ return false;
355
+ return true;
356
+ }
357
+ if (isPlainObject(a) && isPlainObject(b)) {
358
+ const aKeys = Object.keys(a);
359
+ const bKeys = Object.keys(b);
360
+ charge(ctx, Math.floor((aKeys.length + bKeys.length) / MEMBERS_PER_UNIT));
361
+ if (aKeys.length !== bKeys.length)
362
+ return false;
363
+ return aKeys.every((k) => Object.hasOwn(b, k) && sameJson(a[k], b[k], ctx));
364
+ }
122
365
  return false;
123
366
  }
367
+ const enumIndexes = new WeakMap();
368
+ function enumIndex(candidates, ctx) {
369
+ const known = enumIndexes.get(candidates);
370
+ if (known !== undefined)
371
+ return known;
372
+ charge(ctx, 1 + Math.floor(candidates.length / MEMBERS_PER_UNIT));
373
+ const primitives = new Set();
374
+ const others = [];
375
+ for (const candidate of candidates) {
376
+ if (candidate === null || typeof candidate !== "object") {
377
+ if (typeof candidate === "string") {
378
+ charge(ctx, Math.floor(candidate.length / CHARS_PER_UNIT));
379
+ }
380
+ primitives.add(candidate);
381
+ }
382
+ else
383
+ others.push(candidate);
384
+ }
385
+ const index = { primitives, others };
386
+ enumIndexes.set(candidates, index);
387
+ return index;
388
+ }
389
+ /** `sameJson(candidate, value)` for some candidate, charged for what it compares. */
390
+ function inEnum(candidates, value, ctx) {
391
+ const index = enumIndex(candidates, ctx);
392
+ if (value === null || typeof value !== "object") {
393
+ // Hashing a string reads it; everything else is one lookup. A primitive
394
+ // equals only a primitive, and a Set's equality is sameJson's for JSON.
395
+ if (typeof value === "string")
396
+ charge(ctx, Math.floor(value.length / CHARS_PER_UNIT));
397
+ return index.primitives.has(value);
398
+ }
399
+ charge(ctx, Math.floor(index.others.length / MEMBERS_PER_UNIT));
400
+ return index.others.some((candidate) => sameJson(candidate, value, ctx));
401
+ }
402
+ const compiledPatterns = new WeakMap();
403
+ function patternsOf(map, ctx) {
404
+ const known = compiledPatterns.get(map);
405
+ if (known !== undefined)
406
+ return known;
407
+ const valid = [];
408
+ const invalid = [];
409
+ for (const source of Object.keys(map)) {
410
+ charge(ctx, 1 + Math.floor(source.length / CHARS_PER_UNIT));
411
+ try {
412
+ // Compiling reads only the pattern; the match is what is not run here.
413
+ new RegExp(source);
414
+ valid.push({ source });
415
+ }
416
+ catch (err) {
417
+ invalid.push({ source, error: err.message });
418
+ }
419
+ }
420
+ const out = { valid, invalid };
421
+ compiledPatterns.set(map, out);
422
+ return out;
423
+ }
424
+ /**
425
+ * Does the schema's `pattern` (or a `patternProperties` key) match `input`?
426
+ * The pattern is the caller's, so it is never run here: the answer comes
427
+ * from `ctx.regex`, resolved in the worker, or from the bounded fallback
428
+ * when there is none. A pattern that could not be run to an answer, or that
429
+ * the screen refuses as a shape that backtracks exponentially, leaves the
430
+ * whole result undetermined: the validator reports no verdict rather than
431
+ * "invalid" (or "valid"). A question not answered yet reads as "no match"
432
+ * for this pass; the caller runs the walk again once it is answered.
433
+ */
434
+ function patternMatches(ctx, pattern, input, where, compiled) {
435
+ // The read is charged by the caller: validateString per string, and
436
+ // validateObject per (key, pattern) test.
437
+ let answer;
438
+ if (ctx.regex !== undefined) {
439
+ answer = ctx.regex.lookup(pattern, "", input);
440
+ }
441
+ else {
442
+ let screened = compiled?.screened;
443
+ if (screened === undefined) {
444
+ screened = screenCallerPattern(pattern, "");
445
+ if (compiled !== undefined)
446
+ compiled.screened = screened;
447
+ }
448
+ answer = screened.ok ? runPatternSync(screened.regex, input) : screened.answer;
449
+ }
450
+ if (answer === undefined)
451
+ return false;
452
+ if (typeof answer === "boolean")
453
+ return answer;
454
+ const shown = pattern.length > 80 ? `${pattern.slice(0, 79)}…` : pattern;
455
+ if ("refused" in answer) {
456
+ throw new Undetermined(`the schema's pattern /${shown}/ at ${where} was not run: ${answer.refused}, so no verdict was reached`);
457
+ }
458
+ throw new Undetermined(`the schema's pattern /${shown}/ at ${where} could not be run to an answer: ${answer.undetermined}, so no verdict was reached`);
459
+ }
124
460
  /** Does `schema` reject `value`? Runs in a scratch context so no errors leak. */
125
- function branchErrors(value, schema, ctx, schemaPath) {
461
+ function branchErrors(value, schema, ctx, path, schemaPath) {
126
462
  const scratch = {
127
463
  root: ctx.root,
128
464
  opts: { assertFormat: ctx.opts.assertFormat, maxErrors: 20 },
129
465
  errors: [],
130
466
  truncated: false,
131
467
  unsupported: ctx.unsupported,
132
- refStack: ctx.refStack,
133
- depth: ctx.depth,
468
+ refs: ctx.refs,
469
+ refBase: `${ctx.refBase}${path}`,
470
+ valueDepth: ctx.valueDepth,
471
+ nesting: ctx.nesting,
472
+ work: ctx.work,
473
+ previews: ctx.previews,
474
+ regex: ctx.regex,
134
475
  };
135
476
  validateNode(value, schema, "", schemaPath, scratch);
136
477
  return scratch.errors;
@@ -142,6 +483,45 @@ function summarizeBranch(errors) {
142
483
  const where = first.path === "" ? "" : ` at ${first.path}`;
143
484
  return `${first.message}${where}`;
144
485
  }
486
+ /**
487
+ * The reasons of every failing alternative, joined, within MAX_MESSAGE_CHARS.
488
+ *
489
+ * When they do not all fit, each is cut to an equal share rather than the
490
+ * last ones dropped, and a reason shorter than its share keeps all of it. A
491
+ * nested union's reason is itself a joined list whose decisive entry is
492
+ * often last: cutting every branch to a fixed 200 characters took
493
+ * `property "chanel_fallback" is not allowed` off the end of the only reason
494
+ * that named the mistake. Past MIN_REASON_CHARS a share, later branches are
495
+ * counted instead of shown.
496
+ */
497
+ function joinReasons(reasons) {
498
+ const room = MAX_MESSAGE_CHARS - 80;
499
+ const separators = 2 * Math.max(0, reasons.length - 1);
500
+ let total = separators;
501
+ for (const reason of reasons)
502
+ total += reason.length;
503
+ if (total <= room)
504
+ return reasons.join("; ");
505
+ // How many fit at the shortest share, and the share they each get.
506
+ const shown = Math.max(1, Math.min(reasons.length, Math.floor((room + 2) / (MIN_REASON_CHARS + 2))));
507
+ const kept = reasons.slice(0, shown);
508
+ // Water-fill: short reasons keep their length, the rest share what is left.
509
+ let left = room - 2 * (kept.length - 1);
510
+ const sorted = kept.map((r) => r.length).sort((a, b) => a - b);
511
+ let share = Math.floor(left / kept.length);
512
+ for (let i = 0; i < sorted.length; i++) {
513
+ const length = sorted[i];
514
+ const fair = Math.floor(left / (sorted.length - i));
515
+ if (length <= fair) {
516
+ left -= length;
517
+ continue;
518
+ }
519
+ share = fair;
520
+ break;
521
+ }
522
+ const out = kept.map((r) => clip(r, Math.max(share, MIN_REASON_CHARS))).join("; ");
523
+ return shown < reasons.length ? `${out} … (+${reasons.length - shown} more)` : out;
524
+ }
145
525
  function validateNumber(value, schema, path, sp, ctx) {
146
526
  const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = schema;
147
527
  if (typeof minimum === "number" && value < minimum) {
@@ -174,9 +554,15 @@ function validateNumber(value, schema, path, sp, ctx) {
174
554
  }
175
555
  function validateString(value, schema, path, sp, ctx) {
176
556
  const { minLength, maxLength, pattern, format } = schema;
557
+ const checksLength = typeof minLength === "number" || typeof maxLength === "number";
558
+ const checksFormat = typeof format === "string" && ctx.opts.assertFormat;
559
+ // Each of these reads the whole string, so each is charged for its length.
560
+ const reads = (checksLength ? 1 : 0) + (typeof pattern === "string" ? 1 : 0) + (checksFormat ? 1 : 0);
561
+ if (reads > 0)
562
+ charge(ctx, reads * Math.floor(value.length / CHARS_PER_UNIT));
177
563
  // Length is counted in code points, as the spec requires, so an emoji
178
564
  // counts as one character rather than as its two UTF-16 units.
179
- const length = [...value].length;
565
+ const length = checksLength ? codePointLength(value) : 0;
180
566
  if (typeof minLength === "number" && length < minLength) {
181
567
  fail(ctx, path, "minLength", joinPointer(sp, "minLength"), `length ${length} is under the minimum of ${minLength}`);
182
568
  }
@@ -184,15 +570,17 @@ function validateString(value, schema, path, sp, ctx) {
184
570
  fail(ctx, path, "maxLength", joinPointer(sp, "maxLength"), `length ${length} is over the maximum of ${maxLength}`);
185
571
  }
186
572
  if (typeof pattern === "string") {
187
- let re = null;
573
+ let valid = true;
188
574
  try {
189
- re = new RegExp(pattern);
575
+ // Compiling reads only the pattern; the match is what is not run here.
576
+ new RegExp(pattern);
190
577
  }
191
578
  catch (err) {
579
+ valid = false;
192
580
  fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `the schema's pattern is not a valid regular expression: ${err.message}`);
193
581
  }
194
- if (re !== null && !re.test(value)) {
195
- fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `${preview(value, 60)} does not match /${pattern}/`);
582
+ if (valid && !patternMatches(ctx, pattern, value, joinPointer(sp, "pattern"))) {
583
+ fail(ctx, path, "pattern", joinPointer(sp, "pattern"), () => `${preview(ctx, value, 60)} does not match /${pattern}/`);
196
584
  }
197
585
  }
198
586
  if (typeof format === "string" && ctx.opts.assertFormat) {
@@ -202,7 +590,7 @@ function validateString(value, schema, path, sp, ctx) {
202
590
  else {
203
591
  const result = checkFormat(value, format);
204
592
  if (!result.valid) {
205
- fail(ctx, path, "format", joinPointer(sp, "format"), `${preview(value, 60)} is not a valid ${format}: ${result.reason}`);
593
+ fail(ctx, path, "format", joinPointer(sp, "format"), () => `${preview(ctx, value, 60)} is not a valid ${format}: ${result.reason}`);
206
594
  }
207
595
  }
208
596
  }
@@ -219,14 +607,17 @@ function validateArray(value, schema, path, sp, ctx) {
219
607
  const seen = new Map();
220
608
  for (let i = 0; i < value.length; i++) {
221
609
  const key = canonicalize(value[i]);
610
+ // An item's canonical form is as long as the item: charged per item.
611
+ charge(ctx, 1 + Math.floor(key.length / CHARS_PER_UNIT));
222
612
  const previous = seen.get(key);
223
613
  if (previous !== undefined) {
224
- fail(ctx, path, "uniqueItems", joinPointer(sp, "uniqueItems"), `items ${previous} and ${i} are equal (${preview(value[i], 60)})`);
614
+ fail(ctx, path, "uniqueItems", joinPointer(sp, "uniqueItems"), () => `items ${previous} and ${i} are equal (${preview(ctx, value[i], 60)})`);
225
615
  break;
226
616
  }
227
617
  seen.set(key, i);
228
618
  }
229
619
  }
620
+ ctx.valueDepth += 1;
230
621
  if (Array.isArray(items)) {
231
622
  // Tuple form: schema i applies to item i; the rest fall to additionalItems.
232
623
  for (let i = 0; i < value.length; i++) {
@@ -251,15 +642,20 @@ function validateArray(value, schema, path, sp, ctx) {
251
642
  }
252
643
  }
253
644
  if (contains !== undefined) {
254
- const matched = value.some((item) => branchErrors(item, contains, ctx, joinPointer(sp, "contains")).length === 0);
645
+ const matched = value.some((item, i) => branchErrors(item, contains, ctx, joinPointer(path, i), joinPointer(sp, "contains")).length === 0);
255
646
  if (!matched) {
647
+ ctx.valueDepth -= 1;
256
648
  fail(ctx, path, "contains", joinPointer(sp, "contains"), "no item matches the contains schema");
649
+ return;
257
650
  }
258
651
  }
652
+ ctx.valueDepth -= 1;
259
653
  }
260
654
  function validateObject(value, schema, path, sp, ctx) {
261
655
  const { properties, patternProperties, additionalProperties, propertyNames, required, minProperties, maxProperties, } = schema;
262
656
  const keys = Object.keys(value);
657
+ // Listing the keys, and the loop over them below, cost the object's size.
658
+ charge(ctx, Math.floor(keys.length / MEMBERS_PER_UNIT));
263
659
  if (Array.isArray(required)) {
264
660
  for (const key of required) {
265
661
  if (typeof key === "string" && !Object.hasOwn(value, key)) {
@@ -273,23 +669,21 @@ function validateObject(value, schema, path, sp, ctx) {
273
669
  if (typeof maxProperties === "number" && keys.length > maxProperties) {
274
670
  fail(ctx, path, "maxProperties", joinPointer(sp, "maxProperties"), `${keys.length} properties, more than ${maxProperties}`);
275
671
  }
672
+ ctx.valueDepth += 1;
276
673
  if (propertyNames !== undefined) {
277
674
  for (const key of keys) {
278
- const errors = branchErrors(key, propertyNames, ctx, joinPointer(sp, "propertyNames"));
675
+ const errors = branchErrors(key, propertyNames, ctx, joinPointer(path, key), joinPointer(sp, "propertyNames"));
279
676
  if (errors.length > 0) {
280
677
  fail(ctx, joinPointer(path, key), "propertyNames", joinPointer(sp, "propertyNames"), `property name "${key}" is not allowed: ${summarizeBranch(errors)}`);
281
678
  }
282
679
  }
283
680
  }
284
- const patterns = [];
681
+ let patterns = [];
285
682
  if (isPlainObject(patternProperties)) {
286
- for (const source of Object.keys(patternProperties)) {
287
- try {
288
- patterns.push([source, new RegExp(source)]);
289
- }
290
- catch (err) {
291
- fail(ctx, path, "patternProperties", joinPointer(joinPointer(sp, "patternProperties"), source), `the schema's pattern is not a valid regular expression: ${err.message}`);
292
- }
683
+ const compiled = patternsOf(patternProperties, ctx);
684
+ patterns = compiled.valid;
685
+ for (const bad of compiled.invalid) {
686
+ fail(ctx, path, "patternProperties", joinPointer(joinPointer(sp, "patternProperties"), bad.source), `the schema's pattern is not a valid regular expression: ${bad.error}`);
293
687
  }
294
688
  }
295
689
  for (const key of keys) {
@@ -298,8 +692,15 @@ function validateObject(value, schema, path, sp, ctx) {
298
692
  covered = true;
299
693
  validateNode(value[key], properties[key], joinPointer(path, key), joinPointer(joinPointer(sp, "properties"), key), ctx);
300
694
  }
301
- for (const [source, re] of patterns) {
302
- if (!re.test(key))
695
+ // Every (key, pattern) test is charged: 8,000 keys under 8,000 patterns
696
+ // ran 64 million tests for 11 s and then answered valid.
697
+ if (patterns.length > 0) {
698
+ charge(ctx, patterns.length * (1 + Math.floor(key.length / CHARS_PER_UNIT)));
699
+ }
700
+ for (const compiled of patterns) {
701
+ const source = compiled.source;
702
+ const at = joinPointer(joinPointer(sp, "patternProperties"), source);
703
+ if (!patternMatches(ctx, source, key, at, compiled))
303
704
  continue;
304
705
  covered = true;
305
706
  validateNode(value[key], patternProperties[source], joinPointer(path, key), joinPointer(joinPointer(sp, "patternProperties"), source), ctx);
@@ -314,6 +715,7 @@ function validateObject(value, schema, path, sp, ctx) {
314
715
  validateNode(value[key], additionalProperties, joinPointer(path, key), joinPointer(sp, "additionalProperties"), ctx);
315
716
  }
316
717
  }
718
+ ctx.valueDepth -= 1;
317
719
  }
318
720
  function validateLogic(value, schema, path, sp, ctx) {
319
721
  const { allOf, anyOf, oneOf, not } = schema;
@@ -326,40 +728,40 @@ function validateLogic(value, schema, path, sp, ctx) {
326
728
  const reasons = [];
327
729
  let anyPassed = false;
328
730
  for (let i = 0; i < anyOf.length && !anyPassed; i++) {
329
- const errors = branchErrors(value, anyOf[i], ctx, joinPointer(joinPointer(sp, "anyOf"), i));
731
+ const errors = branchErrors(value, anyOf[i], ctx, path, joinPointer(joinPointer(sp, "anyOf"), i));
330
732
  if (errors.length === 0)
331
733
  anyPassed = true;
332
734
  else
333
735
  reasons.push(`[${i}] ${summarizeBranch(errors)}`);
334
736
  }
335
737
  if (!anyPassed) {
336
- fail(ctx, path, "anyOf", joinPointer(sp, "anyOf"), `matched none of the ${anyOf.length} alternatives — ${reasons.join("; ")}`);
738
+ fail(ctx, path, "anyOf", joinPointer(sp, "anyOf"), `matched none of the ${anyOf.length} alternatives — ${joinReasons(reasons)}`);
337
739
  }
338
740
  }
339
741
  if (Array.isArray(oneOf)) {
340
742
  const passing = [];
341
743
  const reasons = [];
342
744
  for (let i = 0; i < oneOf.length; i++) {
343
- const errors = branchErrors(value, oneOf[i], ctx, joinPointer(joinPointer(sp, "oneOf"), i));
745
+ const errors = branchErrors(value, oneOf[i], ctx, path, joinPointer(joinPointer(sp, "oneOf"), i));
344
746
  if (errors.length === 0)
345
747
  passing.push(i);
346
748
  else
347
749
  reasons.push(`[${i}] ${summarizeBranch(errors)}`);
348
750
  }
349
751
  if (passing.length === 0) {
350
- fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched none of the ${oneOf.length} alternatives — ${reasons.join("; ")}`);
752
+ fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched none of the ${oneOf.length} alternatives — ${joinReasons(reasons)}`);
351
753
  }
352
754
  else if (passing.length > 1) {
353
755
  fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched ${passing.length} alternatives (${passing.join(", ")}) but oneOf allows exactly one`);
354
756
  }
355
757
  }
356
758
  if (not !== undefined) {
357
- if (branchErrors(value, not, ctx, joinPointer(sp, "not")).length === 0) {
759
+ if (branchErrors(value, not, ctx, path, joinPointer(sp, "not")).length === 0) {
358
760
  fail(ctx, path, "not", joinPointer(sp, "not"), "matched a schema it must not match");
359
761
  }
360
762
  }
361
763
  if (schema["if"] !== undefined) {
362
- const conditionHolds = branchErrors(value, schema["if"], ctx, joinPointer(sp, "if")).length === 0;
764
+ const conditionHolds = branchErrors(value, schema["if"], ctx, path, joinPointer(sp, "if")).length === 0;
363
765
  const branch = conditionHolds ? schema["then"] : schema["else"];
364
766
  if (branch !== undefined) {
365
767
  validateNode(value, branch, path, joinPointer(sp, conditionHolds ? "then" : "else"), ctx);
@@ -391,10 +793,22 @@ function resolveRef(ref, ctx) {
391
793
  }
392
794
  /** Validate one value against one schema, appending any failures to `ctx`. */
393
795
  function validateNode(value, schema, path, sp, ctx) {
394
- if (ctx.depth > MAX_DEPTH) {
395
- fail(ctx, path, "depth", sp, `nesting passed ${MAX_DEPTH} levels — either the value is that deep or a $ref is cycling`);
396
- return;
796
+ charge(ctx, 1);
797
+ if (ctx.valueDepth > MAX_VALUE_DEPTH) {
798
+ throw new Undetermined(`the value nests deeper than ${MAX_VALUE_DEPTH} levels (at ${where(ctx, path)}), and nothing below that was checked`);
799
+ }
800
+ if (ctx.nesting >= MAX_NESTING) {
801
+ throw new Undetermined(`the schema and value nest past ${MAX_NESTING} checks inside one another (at ${where(ctx, path)}), and nothing below that was checked`);
397
802
  }
803
+ ctx.nesting += 1;
804
+ try {
805
+ checkNode(value, schema, path, sp, ctx);
806
+ }
807
+ finally {
808
+ ctx.nesting -= 1;
809
+ }
810
+ }
811
+ function checkNode(value, schema, path, sp, ctx) {
398
812
  if (schema === true)
399
813
  return;
400
814
  if (schema === false) {
@@ -418,8 +832,10 @@ function validateNode(value, schema, path, sp, ctx) {
418
832
  // Draft-07: a `$ref` replaces its sibling keywords entirely.
419
833
  if (typeof schema["$ref"] === "string") {
420
834
  const ref = schema["$ref"];
421
- const marker = `${ref}@${path}`;
422
- if (ctx.refStack.includes(marker)) {
835
+ // Keyed on the ABSOLUTE instance position: the same $ref twice at one
836
+ // place in the value, with nothing consumed between, is a real cycle.
837
+ const marker = `${ref}@${ctx.refBase}${path}`;
838
+ if (ctx.refs.has(marker)) {
423
839
  fail(ctx, path, "$ref", joinPointer(sp, "$ref"), `$ref "${ref}" cycles at this position`);
424
840
  return;
425
841
  }
@@ -428,11 +844,13 @@ function validateNode(value, schema, path, sp, ctx) {
428
844
  fail(ctx, path, "$ref", joinPointer(sp, "$ref"), resolved.error);
429
845
  return;
430
846
  }
431
- ctx.refStack.push(marker);
432
- ctx.depth += 1;
433
- validateNode(value, resolved.schema, path, ref, ctx);
434
- ctx.depth -= 1;
435
- ctx.refStack.pop();
847
+ ctx.refs.add(marker);
848
+ try {
849
+ validateNode(value, resolved.schema, path, ref, ctx);
850
+ }
851
+ finally {
852
+ ctx.refs.delete(marker);
853
+ }
436
854
  return;
437
855
  }
438
856
  const actual = typeOf(value);
@@ -440,21 +858,21 @@ function validateNode(value, schema, path, sp, ctx) {
440
858
  if (typeof declared === "string" || Array.isArray(declared)) {
441
859
  const allowed = (Array.isArray(declared) ? declared : [declared]).filter((t) => typeof t === "string");
442
860
  if (allowed.length > 0 && !allowed.some((t) => matchesType(actual, t))) {
443
- fail(ctx, path, "type", joinPointer(sp, "type"), `expected ${allowed.join(" or ")}, found ${actual} (${preview(value, 60)})`);
861
+ fail(ctx, path, "type", joinPointer(sp, "type"), () => `expected ${allowed.join(" or ")}, found ${actual} (${preview(ctx, value, 60)})`);
444
862
  // A wrong type makes every type-specific keyword below noise.
445
863
  validateLogic(value, schema, path, sp, ctx);
446
864
  return;
447
865
  }
448
866
  }
449
867
  if (Array.isArray(schema["enum"])) {
450
- if (!schema["enum"].some((candidate) => deepEqual(candidate, value))) {
451
- fail(ctx, path, "enum", joinPointer(sp, "enum"), `${preview(value, 60)} is not one of ${preview(schema["enum"], 120)}`);
868
+ const candidates = schema["enum"];
869
+ if (!inEnum(candidates, value, ctx)) {
870
+ fail(ctx, path, "enum", joinPointer(sp, "enum"), () => `${preview(ctx, value, 60)} is not one of ${preview(ctx, candidates, 120)}`);
452
871
  }
453
872
  }
454
- if (Object.hasOwn(schema, "const") && !deepEqual(schema["const"], value)) {
455
- fail(ctx, path, "const", joinPointer(sp, "const"), `expected ${preview(schema["const"], 60)}, found ${preview(value, 60)}`);
873
+ if (Object.hasOwn(schema, "const") && !sameJson(schema["const"], value, ctx)) {
874
+ fail(ctx, path, "const", joinPointer(sp, "const"), () => `expected ${preview(ctx, schema["const"], 60)}, found ${preview(ctx, value, 60)}`);
456
875
  }
457
- ctx.depth += 1;
458
876
  if (typeof value === "number")
459
877
  validateNumber(value, schema, path, sp, ctx);
460
878
  else if (typeof value === "string")
@@ -464,7 +882,6 @@ function validateNode(value, schema, path, sp, ctx) {
464
882
  else if (isPlainObject(value))
465
883
  validateObject(value, schema, path, sp, ctx);
466
884
  validateLogic(value, schema, path, sp, ctx);
467
- ctx.depth -= 1;
468
885
  }
469
886
  /**
470
887
  * Validate `value` against `schema`, returning every failure with a JSON
@@ -473,23 +890,73 @@ function validateNode(value, schema, path, sp, ctx) {
473
890
  * validator did not enforce, so a pass can be read honestly.
474
891
  */
475
892
  export function validateValue(value, schema, options = {}) {
893
+ const work = options.budget ?? {
894
+ used: 0,
895
+ limit: options.maxWork ?? defaultWorkLimit(value, schema),
896
+ };
476
897
  const ctx = {
477
898
  root: schema,
478
899
  opts: { assertFormat: options.assertFormat ?? false, maxErrors: options.maxErrors ?? 100 },
479
900
  errors: [],
480
901
  truncated: false,
481
902
  unsupported: new Set(),
482
- refStack: [],
483
- depth: 0,
903
+ refs: new Set(),
904
+ refBase: "",
905
+ valueDepth: 0,
906
+ nesting: 0,
907
+ work,
908
+ previews: new WeakMap(),
909
+ regex: options.regex,
484
910
  };
485
- validateNode(value, schema, "", "", ctx);
911
+ let undetermined = null;
912
+ let workExhausted = false;
913
+ try {
914
+ validateNode(value, schema, "", "", ctx);
915
+ }
916
+ catch (err) {
917
+ if (err instanceof WorkExhausted) {
918
+ workExhausted = true;
919
+ undetermined = `the schema needed more than ${work.limit} subschema evaluations for this value, so no verdict was reached — anyOf, oneOf and allOf over shared $refs multiply, and so do many patternProperties over many keys`;
920
+ }
921
+ else if (err instanceof Undetermined) {
922
+ undetermined = err.message;
923
+ }
924
+ else if (err instanceof RangeError && /call stack/i.test(err.message)) {
925
+ // The caps above stop the walk first on the shapes measured; a value
926
+ // item compared whole (uniqueItems) can still nest past the stack.
927
+ undetermined =
928
+ "the schema or value nests too deep for this validator to walk, so no verdict was reached";
929
+ }
930
+ else {
931
+ throw err;
932
+ }
933
+ }
486
934
  return {
487
- valid: ctx.errors.length === 0,
935
+ valid: undetermined === null && ctx.errors.length === 0,
936
+ undetermined,
937
+ workExhausted,
488
938
  errors: ctx.errors,
489
939
  truncated: ctx.truncated,
490
940
  unsupportedKeywords: [...ctx.unsupported].sort(),
491
941
  };
492
942
  }
943
+ /**
944
+ * Why a schema's pattern will not be run, or null: it does not compile, or
945
+ * the caller-pattern screen refuses it (a shape that backtracks
946
+ * exponentially, such as `(a+)+`, or one too long to analyse). Either way
947
+ * the schema is reported as malformed before anything is validated, rather
948
+ * than run and answered "no match" by an engine that gave up.
949
+ */
950
+ function refusedPattern(pattern) {
951
+ try {
952
+ new RegExp(pattern);
953
+ }
954
+ catch (err) {
955
+ return err.message;
956
+ }
957
+ const screened = screenCallerPattern(pattern, "");
958
+ return screened.ok ? null : screened.answer.refused;
959
+ }
493
960
  /**
494
961
  * A structural sanity check on the schema itself, run before validation so a
495
962
  * typo like `{"type": "sting"}` is reported as a bad schema rather than as
@@ -511,7 +978,7 @@ export function checkSchemaShape(schema, path = "", depth = 0) {
511
978
  }
512
979
  // A schema is finite JSON, so this only guards against a hand-built cyclic
513
980
  // object; it costs nothing and cannot be hit by a parsed document.
514
- if (depth > MAX_DEPTH)
981
+ if (depth > MAX_SHAPE_DEPTH)
515
982
  return problems;
516
983
  const validTypes = ["null", "boolean", "object", "array", "number", "string", "integer"];
517
984
  const declared = schema["type"];
@@ -544,12 +1011,9 @@ export function checkSchemaShape(schema, path = "", depth = 0) {
544
1011
  }
545
1012
  }
546
1013
  if (typeof schema["pattern"] === "string") {
547
- try {
548
- new RegExp(schema["pattern"]);
549
- }
550
- catch (err) {
551
- problems.push(`${joinPointer(path, "pattern")}: ${err.message}`);
552
- }
1014
+ const refused = refusedPattern(schema["pattern"]);
1015
+ if (refused !== null)
1016
+ problems.push(`${joinPointer(path, "pattern")}: ${refused}`);
553
1017
  }
554
1018
  const descend = (child, childPath) => {
555
1019
  problems.push(...checkSchemaShape(child, childPath, depth + 1));
@@ -561,11 +1025,9 @@ export function checkSchemaShape(schema, path = "", depth = 0) {
561
1025
  continue;
562
1026
  if (keyword === "patternProperties") {
563
1027
  for (const source of Object.keys(map)) {
564
- try {
565
- new RegExp(source);
566
- }
567
- catch (err) {
568
- problems.push(`${joinPointer(joinPointer(path, keyword), source)}: ${err.message}`);
1028
+ const refused = refusedPattern(source);
1029
+ if (refused !== null) {
1030
+ problems.push(`${joinPointer(joinPointer(path, keyword), source)}: ${refused}`);
569
1031
  }
570
1032
  }
571
1033
  }