@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 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
@@ -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;AAGrC;;;;;;;;;GASG;AACH,KAAK,SAAS,GAAG,OAAO,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AA+SxC;;;;;;;;;;;;;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,CAAkE,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 base = scoreUnion(propSchema, raw, lazyCache, scoringCache);
85
- if (isDiscriminatorProperty(propSchema)) {
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
- return scoreUnion(schema.schema, schema.expression(value), lazyCache, scoringCache);
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 });
package/package.json CHANGED
@@ -15,7 +15,7 @@
15
15
  "coerce",
16
16
  "scalar"
17
17
  ],
18
- "version": "0.6.4",
18
+ "version": "0.6.5",
19
19
  "engines": {
20
20
  "node": ">=20"
21
21
  },