@scalar/validation 0.6.4 → 0.6.5
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/CHANGELOG.md +6 -0
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +96 -19
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @scalar/validation
|
|
2
2
|
|
|
3
|
+
## 0.6.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#10315](https://github.com/scalar/scalar/pull/10315): Bound how deep union scoring looks into a value, so `coerce` no longer takes exponential time on recursive unions. `coerce` now also stops at a nesting depth of 1,000 calls instead of overflowing the stack. It leaves deeper values unchanged and logs a warning.
|
|
8
|
+
|
|
3
9
|
## 0.6.4
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/coerce.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"coerce.d.ts","sourceRoot":"","sources":["../src/coerce.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"coerce.d.ts","sourceRoot":"","sources":["../src/coerce.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AA6CrC;;;;;;;;;GASG;AACH,KAAK,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAqWxC;;;;;;;;;;;;;GAaG;AACH,KAAK,UAAU,CAAC,CAAC,SAAS,MAAM,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,GAAG,GAAG,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;AAE1E;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,MAAM,GAAI,CAAC,SAAS,MAAM,EACrC,QAAQ,CAAC,EACT,OAAO,OAAO,EACd,QAAO,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC,CAAiB,EAC5D,YAAW,SAAyB,KACnC,UAAU,CAAC,CAAC,CAAyF,CAAA"}
|
package/dist/coerce.js
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
import { isObject } from './helpers/is-object.js';
|
|
2
2
|
import { validate } from './validate.js';
|
|
3
|
+
/**
|
|
4
|
+
* How many object levels below a union node `scoreUnion` descends before it
|
|
5
|
+
* stops scoring child values. Picking a union branch is a local decision, so
|
|
6
|
+
* the shape near the union node is what matters. Scoring the whole subtree
|
|
7
|
+
* made the cost grow as 2^depth on recursive unions (a few hundred bytes of
|
|
8
|
+
* JSON could block the main thread for seconds). It also let a branch win
|
|
9
|
+
* just because the value under it happened to be deep.
|
|
10
|
+
*
|
|
11
|
+
* Three is a safety margin, not a derived minimum. The existing
|
|
12
|
+
* branch-selection tests only need the discriminator to be scored, which
|
|
13
|
+
* happens at any depth.
|
|
14
|
+
*/
|
|
15
|
+
const MAX_VALUE_DEPTH = 3;
|
|
16
|
+
/**
|
|
17
|
+
* How many `lazy` nodes deep one scoring path may go before it returns a
|
|
18
|
+
* neutral score. `lazy` is the only way to build an infinite schema, so this
|
|
19
|
+
* stops schema cycles that never descend into a value, such as
|
|
20
|
+
* `T = lazy(() => union([T, string()]))` scored against `'s'`.
|
|
21
|
+
*
|
|
22
|
+
* It limits depth, not fan-out. The in-progress guard only tracks objects, so
|
|
23
|
+
* a schema cycle that branches over a primitive can still do a lot of work.
|
|
24
|
+
* Only a schema author can build one of those, and the OpenAPI and AsyncAPI
|
|
25
|
+
* schemas do not contain one.
|
|
26
|
+
*/
|
|
27
|
+
const MAX_LAZY_DEPTH = 64;
|
|
28
|
+
/**
|
|
29
|
+
* How many nested `coerceInner` calls one `coerce` call makes before it
|
|
30
|
+
* stops and returns the remaining value unchanged. This has to fire before
|
|
31
|
+
* the JavaScript stack overflows, or it does nothing. Without it, a deeply
|
|
32
|
+
* nested document, or a schema cycle over a primitive such as
|
|
33
|
+
* `lazy(() => union([T, string()]))`, throws a `RangeError`.
|
|
34
|
+
*
|
|
35
|
+
* Measured against the real OpenAPI Schema Object in Node, the stack
|
|
36
|
+
* overflows at about 3,200 nested calls. That comes from 6 to 11 calls per
|
|
37
|
+
* document level, depending on shape. We stop at roughly a third of that,
|
|
38
|
+
* because browsers, web workers and the caller's own frames can leave less
|
|
39
|
+
* stack. Schema Objects nested 90 to 165 levels deep still coerce fully.
|
|
40
|
+
*/
|
|
41
|
+
const MAX_COERCE_DEPTH = 1000;
|
|
3
42
|
const resolveLazy = (schema, lazyCache) => {
|
|
4
43
|
const cached = lazyCache.get(schema);
|
|
5
44
|
if (cached) {
|
|
@@ -45,8 +84,20 @@ const isDiscriminatorProperty = (schema) => {
|
|
|
45
84
|
* Markers are removed in `finally` so sibling union branches that share a
|
|
46
85
|
* schema reference are scored independently rather than inheriting a stale
|
|
47
86
|
* "in cycle" marker.
|
|
87
|
+
*
|
|
88
|
+
* The marker only stops *nested* re-entry. It does not stop the same pair
|
|
89
|
+
* being scored again through a sibling path, so it cannot bound the work on
|
|
90
|
+
* its own. Two budgets do that, each checked in the branch that uses it:
|
|
91
|
+
* - `valueDepth` counts descents into object properties below the union node.
|
|
92
|
+
* Past {@link MAX_VALUE_DEPTH}, non-discriminator properties score a flat `1`
|
|
93
|
+
* and are not descended into.
|
|
94
|
+
* - `lazyDepth` counts `lazy` nodes on the current path, capped at {@link MAX_LAZY_DEPTH}.
|
|
95
|
+
*
|
|
96
|
+
* Every schema node still runs its own type check at the cap. So an `object`
|
|
97
|
+
* schema against a string still scores `0`, and the budgets only cut off
|
|
98
|
+
* descent into child values.
|
|
48
99
|
*/
|
|
49
|
-
const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
|
|
100
|
+
const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap(), valueDepth = 0, lazyDepth = 0) => {
|
|
50
101
|
// Short-circuit on cycles: this exact (value, schema) pair is already being
|
|
51
102
|
// scored higher up the call stack. The enclosing call's score subsumes any
|
|
52
103
|
// contribution we could compute here, so return a neutral positive score.
|
|
@@ -81,8 +132,17 @@ const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
|
|
|
81
132
|
}
|
|
82
133
|
const propSchema = schema.properties[key];
|
|
83
134
|
const raw = value[key];
|
|
84
|
-
const
|
|
85
|
-
|
|
135
|
+
const isDiscriminator = isDiscriminatorProperty(propSchema);
|
|
136
|
+
// Past the depth budget we stop descending into child values. Discriminators are still
|
|
137
|
+
// scored in full: they are finite trees of literals, optionals and unions (never `lazy`)
|
|
138
|
+
// that do not descend into the value, so this stays cheap. It also keeps a tag like
|
|
139
|
+
// `kind: literal('b')` deciding the branch when it sits below the budget.
|
|
140
|
+
const base = valueDepth >= MAX_VALUE_DEPTH
|
|
141
|
+
? isDiscriminator
|
|
142
|
+
? scoreUnion(propSchema, raw, lazyCache, scoringCache, valueDepth, lazyDepth)
|
|
143
|
+
: 1
|
|
144
|
+
: scoreUnion(propSchema, raw, lazyCache, scoringCache, valueDepth + 1, lazyDepth);
|
|
145
|
+
if (isDiscriminator) {
|
|
86
146
|
return acc + (base > 0 ? base * 10 : 0);
|
|
87
147
|
}
|
|
88
148
|
return acc + (base > 0 ? base : 1);
|
|
@@ -97,25 +157,32 @@ const scoreUnion = (schema, value, lazyCache, scoringCache = new WeakMap()) => {
|
|
|
97
157
|
return isObject(value) ? 1 : 0;
|
|
98
158
|
}
|
|
99
159
|
if (schema.type === 'optional') {
|
|
100
|
-
return value === undefined ? 1 : scoreUnion(schema.schema, value, lazyCache, scoringCache);
|
|
160
|
+
return value === undefined ? 1 : scoreUnion(schema.schema, value, lazyCache, scoringCache, valueDepth, lazyDepth);
|
|
101
161
|
}
|
|
102
162
|
if (schema.type === 'union') {
|
|
103
163
|
// For a union, use the highest score among all sub-schemas
|
|
104
|
-
return Math.max(...schema.schemas.map((branch) => scoreUnion(branch, value, lazyCache, scoringCache)));
|
|
164
|
+
return Math.max(...schema.schemas.map((branch) => scoreUnion(branch, value, lazyCache, scoringCache, valueDepth, lazyDepth)));
|
|
105
165
|
}
|
|
106
166
|
if (schema.type === 'intersection') {
|
|
107
167
|
if (schema.schemas.length === 0) {
|
|
108
168
|
return 1;
|
|
109
169
|
}
|
|
110
|
-
return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value, lazyCache, scoringCache), 0);
|
|
170
|
+
return schema.schemas.reduce((acc, sub) => acc + scoreUnion(sub, value, lazyCache, scoringCache, valueDepth, lazyDepth), 0);
|
|
111
171
|
}
|
|
112
172
|
if (schema.type === 'lazy') {
|
|
173
|
+
// We cannot know the type without resolving, so a neutral score is the only option here.
|
|
174
|
+
if (lazyDepth >= MAX_LAZY_DEPTH) {
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
113
177
|
// For a lazy schema, evaluate the inner schema and recurse
|
|
114
|
-
return scoreUnion(resolveLazy(schema, lazyCache), value, lazyCache, scoringCache);
|
|
178
|
+
return scoreUnion(resolveLazy(schema, lazyCache), value, lazyCache, scoringCache, valueDepth, lazyDepth + 1);
|
|
115
179
|
}
|
|
116
180
|
if (schema.type === 'evaluate') {
|
|
117
|
-
// For an evaluate schema, evaluate the expression and recurse
|
|
118
|
-
|
|
181
|
+
// For an evaluate schema, evaluate the expression and recurse. This spends no budget: the
|
|
182
|
+
// inner `object` branch still stops descending at the cap, and `lazy` still bounds cycles.
|
|
183
|
+
// Only a schema that points `evaluate` back at itself without a `lazy` in between could
|
|
184
|
+
// loop, and the builders in `schema.ts` cannot construct one.
|
|
185
|
+
return scoreUnion(schema.schema, schema.expression(value), lazyCache, scoringCache, valueDepth, lazyDepth);
|
|
119
186
|
}
|
|
120
187
|
// For primitives and any other type, return 1 if valid, otherwise 0
|
|
121
188
|
return validate(schema, value) ? 1 : 0;
|
|
@@ -148,7 +215,17 @@ const trackCycle = (value, schema, result, cache) => {
|
|
|
148
215
|
* can overflow the type checker now that `LazyStatic` resolves recursive schemas without a depth
|
|
149
216
|
* cap. The public `coerce` wrapper preserves the typed surface.
|
|
150
217
|
*/
|
|
151
|
-
const coerceInner = (schema, value, cache, lazyCache) => {
|
|
218
|
+
const coerceInner = (schema, value, cache, lazyCache, depth, warningState) => {
|
|
219
|
+
// Stop before the stack overflows. Return the value unchanged, not a schema default: callers
|
|
220
|
+
// merge the result back into the document, so a default would overwrite real content. Leaving
|
|
221
|
+
// the subtree un-normalized loses nothing.
|
|
222
|
+
if (depth >= MAX_COERCE_DEPTH) {
|
|
223
|
+
if (!warningState.emitted) {
|
|
224
|
+
warningState.emitted = true;
|
|
225
|
+
console.warn(`[@scalar/validation] coerce stopped at nesting depth ${MAX_COERCE_DEPTH}; deeper values are left as-is.`);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
152
229
|
// Prevent infinite recursion by returning the in-progress result that was
|
|
153
230
|
// staged by an enclosing call via trackCycle.
|
|
154
231
|
if ((isObject(value) || Array.isArray(value)) && cache.get(value)?.has(schema)) {
|
|
@@ -195,7 +272,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
195
272
|
if (value === undefined) {
|
|
196
273
|
return undefined;
|
|
197
274
|
}
|
|
198
|
-
return coerceInner(schema.schema, value, cache, lazyCache);
|
|
275
|
+
return coerceInner(schema.schema, value, cache, lazyCache, depth + 1, warningState);
|
|
199
276
|
}
|
|
200
277
|
if (schema.type === 'array') {
|
|
201
278
|
if (!Array.isArray(value)) {
|
|
@@ -206,7 +283,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
206
283
|
const result = new Array(value.length);
|
|
207
284
|
trackCycle(value, schema, result, cache);
|
|
208
285
|
for (let i = 0; i < value.length; i++) {
|
|
209
|
-
result[i] = coerceInner(schema.items, value[i], cache, lazyCache);
|
|
286
|
+
result[i] = coerceInner(schema.items, value[i], cache, lazyCache, depth + 1, warningState);
|
|
210
287
|
}
|
|
211
288
|
return result;
|
|
212
289
|
}
|
|
@@ -219,7 +296,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
219
296
|
const result = {};
|
|
220
297
|
trackCycle(value, schema, result, cache);
|
|
221
298
|
for (const key of Object.keys(value)) {
|
|
222
|
-
result[key] = coerceInner(schema.value, value[key], cache, lazyCache);
|
|
299
|
+
result[key] = coerceInner(schema.value, value[key], cache, lazyCache, depth + 1, warningState);
|
|
223
300
|
}
|
|
224
301
|
return result;
|
|
225
302
|
}
|
|
@@ -236,7 +313,7 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
236
313
|
if (propSchema.type === 'optional' && raw === undefined) {
|
|
237
314
|
continue;
|
|
238
315
|
}
|
|
239
|
-
result[key] = coerceInner(propSchema, raw, cache, lazyCache);
|
|
316
|
+
result[key] = coerceInner(propSchema, raw, cache, lazyCache, depth + 1, warningState);
|
|
240
317
|
}
|
|
241
318
|
return result;
|
|
242
319
|
}
|
|
@@ -246,19 +323,19 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
246
323
|
return score > acc.score ? { schema: branchSchema, score } : acc;
|
|
247
324
|
}, { schema: schema.schemas[0], score: 0 });
|
|
248
325
|
// We need some way to pick one of the union values
|
|
249
|
-
return coerceInner(branch.schema, value, cache, lazyCache);
|
|
326
|
+
return coerceInner(branch.schema, value, cache, lazyCache, depth + 1, warningState);
|
|
250
327
|
}
|
|
251
328
|
if (schema.type === 'intersection') {
|
|
252
|
-
return schema.schemas.reduce((acc, subSchema) => Object.assign(acc, coerceInner(subSchema, value, cache, lazyCache)), {});
|
|
329
|
+
return schema.schemas.reduce((acc, subSchema) => Object.assign(acc, coerceInner(subSchema, value, cache, lazyCache, depth + 1, warningState)), {});
|
|
253
330
|
}
|
|
254
331
|
if (schema.type === 'literal') {
|
|
255
332
|
return schema.value;
|
|
256
333
|
}
|
|
257
334
|
if (schema.type === 'lazy') {
|
|
258
|
-
return coerceInner(resolveLazy(schema, lazyCache), value, cache, lazyCache);
|
|
335
|
+
return coerceInner(resolveLazy(schema, lazyCache), value, cache, lazyCache, depth + 1, warningState);
|
|
259
336
|
}
|
|
260
337
|
if (schema.type === 'evaluate') {
|
|
261
|
-
return coerceInner(schema.schema, schema.expression(value), cache, lazyCache);
|
|
338
|
+
return coerceInner(schema.schema, schema.expression(value), cache, lazyCache, depth + 1, warningState);
|
|
262
339
|
}
|
|
263
340
|
// We need to assert here that schema has the type never so we know we handle all cases
|
|
264
341
|
const _exhaustive = schema;
|
|
@@ -283,4 +360,4 @@ const coerceInner = (schema, value, cache, lazyCache) => {
|
|
|
283
360
|
* The optional `cache` argument tracks visited object–schema pairs to stop infinite recursion
|
|
284
361
|
* on cyclic graphs; callers normally omit it.
|
|
285
362
|
*/
|
|
286
|
-
export const coerce = (schema, value, cache = new WeakMap(), lazyCache = new WeakMap()) => coerceInner(schema, value, cache, lazyCache);
|
|
363
|
+
export const coerce = (schema, value, cache = new WeakMap(), lazyCache = new WeakMap()) => coerceInner(schema, value, cache, lazyCache, 0, { emitted: false });
|