@crewhaus/tool-schema 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +50 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.js +92 -13
- package/dist/lib/assert.d.ts +36 -1
- package/dist/lib/assert.js +95 -14
- package/dist/lib/compare.js +30 -9
- package/dist/lib/formats.d.ts +13 -0
- package/dist/lib/formats.js +14 -1
- package/dist/lib/jsonschema.d.ts +170 -0
- package/dist/lib/jsonschema.js +530 -68
- package/dist/lib/records.d.ts +16 -0
- package/dist/lib/records.js +17 -3
- package/dist/lib/regex-answers.d.ts +142 -0
- package/dist/lib/regex-answers.js +289 -0
- package/dist/lib/schemadiff.d.ts +19 -0
- package/dist/lib/schemadiff.js +98 -17
- package/dist/lib/shape-index.d.ts +22 -0
- package/dist/lib/shape-index.js +157 -0
- package/dist/lib/summarize.js +28 -9
- package/dist/lib/value.d.ts +16 -2
- package/dist/lib/value.js +143 -13
- package/package.json +11 -6
package/dist/lib/jsonschema.js
CHANGED
|
@@ -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 {
|
|
56
|
-
import { canonicalize,
|
|
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
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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 =
|
|
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
|
|
573
|
+
let valid = true;
|
|
188
574
|
try {
|
|
189
|
-
|
|
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 (
|
|
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
|
-
|
|
681
|
+
let patterns = [];
|
|
285
682
|
if (isPlainObject(patternProperties)) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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") && !
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
548
|
-
|
|
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
|
-
|
|
565
|
-
|
|
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
|
}
|