diff --git a/src/compile.js b/src/compile.js index f0f57d1..e0d97fc 100644 --- a/src/compile.js +++ b/src/compile.js @@ -48,6 +48,7 @@ const constantValue = (schema) => { return undefined } +const refsNeedFullValidation = new WeakSet() const rootMeta = new WeakMap() const generateMeta = (root, $schema, enforce, requireSchema) => { if ($schema) { @@ -1184,19 +1185,20 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { fail('failed to resolve $ref:', $ref) if (lintOnly) return null // failures are just collected in linter mode and don't throw, this makes a ref noop } - // evaluation prior to ref application is needed for recursion cases - // This could be done better, but for now we check only the direct rules in the $ref - if (sub.type) evaluateDelta({ type: Array.isArray(sub.type) ? sub.type : [sub.type] }) - for (const t of ['additionalProperties', 'unevaluatedProperties']) { - if (functions.hasOwn(sub, t) && isSchemaish(sub[t])) evaluateDelta({ properties: [true] }) - } - for (const t of ['additionalItems', 'unevaluatedItems', 'items']) { - if (functions.hasOwn(sub, t) && isSchemaish(sub[t])) evaluateDelta({ items: Infinity }) - } - for (const t of ['pattern', 'format']) { - if (typeof sub[t] === 'string') evaluateDelta({ fullstring: true }) + const n = compileSub(sub, subRoot, path) + enforce(sub === schema || scope[n], 'coherence check') + if ((sub === schema || !scope[n][evaluatedStatic]) && sub.type) { + const type = Array.isArray(sub.type) ? sub.type : [sub.type] + evaluateDelta({ type }) + if (requireValidation) { + // We are inside a cyclic ref, label it as a one that needs full validation to support assumption in next clause + refsNeedFullValidation.add(n) + // If validation is required, then a cyclic $ref is guranteed to validate all items and properties + if (type.includes('array')) evaluateDelta({ items: Infinity }) + if (type.includes('object')) evaluateDelta({ properties: [true] }) + } } - return applyRef(compileSub(sub, subRoot, path), { path: ['$ref'] }) + return applyRef(n, { path: ['$ref'] }) }) if (getMeta().exclusiveRefs) { enforce(!opts[optDynamic], 'unevaluated* is supported only on draft2019-09 and above') @@ -1300,7 +1302,7 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { // 'not' does not mark anything as evaluated (unlike even if/then/else), so it's safe to exclude from these // checks, as we are sure that everything will be checked without it. It can be viewed as a pure add-on. const isRefTop = schema !== root && node === schema // We are at the top-level of an opaque ref inside the schema object - if (!isRefTop) { + if (!isRefTop || refsNeedFullValidation.has(funname)) { if (!stat.type) enforceValidation('type') // This can't be true for top-level schemas, only references with #/... if (typeApplicable('array') && stat.items !== Infinity) diff --git a/test/regressions/strong-refs.js b/test/regressions/strong-refs.js index 631f06c..8f6099c 100644 --- a/test/regressions/strong-refs.js +++ b/test/regressions/strong-refs.js @@ -278,3 +278,178 @@ tape('partially validating local ref can be used (strings)', (t) => { }) t.end() }) + +tape('cyclic local refs do full validation', (t) => { + const compile = ($defs) => + validator( + { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $defs, + $ref: '#/$defs/A', + }, + { mode: 'strong' } + ) + + t.throws(() => { + compile({ + A: { + type: ['array'], + }, + }) + }) + + t.throws(() => { + compile({ + A: { + type: ['array', 'object'], + items: { + $ref: '#/$defs/A', + }, + }, + }) + }) + + t.doesNotThrow(() => { + const validate = compile({ + A: { + type: ['array'], + items: { + $ref: '#/$defs/A', + }, + }, + }) + t.notOk(validate({}), '{}') + t.ok(validate([]), '[]') + t.ok(validate([[]]), '[[]]') + t.ok(validate([[[]]]), '[[[]]]') + t.notOk(validate([1]), '[1]') + t.notOk(validate([{}]), '[{}]') + }) + + t.doesNotThrow(() => { + const validate = compile({ + A: { + type: ['array', 'object'], + oneOf: [ + { + type: 'array', + items: { + $ref: '#/$defs/A', + }, + }, + { + type: 'object', + required: [], + additionalProperties: false, + properties: { + x: { $ref: '#/$defs/A' }, + }, + }, + ], + }, + }) + t.ok(validate({}), '{}') + t.ok(validate([]), '[]') + t.ok(validate([[]]), '[[]]') + t.ok(validate([[[]]]), '[[[]]]') + t.notOk(validate([1]), '[1]') + t.ok(validate([{}]), '[{}]') + }) + + t.throws(() => { + compile({ + A: { + type: ['array', 'object'], + oneOf: [ + { + type: 'array', + items: { + $ref: '#/$defs/B', + }, + }, + { + type: 'object', + required: [], + additionalProperties: false, + properties: { + x: { $ref: '#/$defs/B' }, + }, + }, + ], + }, + B: { + type: ['array', 'object'], + oneOf: [ + { + type: 'array', + }, + { + type: 'object', + required: [], + additionalProperties: false, + properties: { + x: { $ref: '#/$defs/A' }, + }, + }, + ], + }, + }) + t.ok(validate({}), '{}') + t.ok(validate([]), '[]') + t.ok(validate([[]]), '[[]]') + t.ok(validate([[[]]]), '[[[]]]') + t.notOk(validate([1]), '[1]') + t.ok(validate([{}]), '[{}]') + }) + + t.doesNotThrow(() => { + const validate = compile({ + A: { + type: ['array', 'object'], + oneOf: [ + { + type: 'array', + items: { + $ref: '#/$defs/B', + }, + }, + { + type: 'object', + required: [], + additionalProperties: false, + properties: { + x: { $ref: '#/$defs/B' }, + }, + }, + ], + }, + B: { + type: ['array', 'object'], + oneOf: [ + { + type: 'array', + items: { + $ref: '#/$defs/A', + }, + }, + { + type: 'object', + required: [], + additionalProperties: false, + properties: { + x: { $ref: '#/$defs/A' }, + }, + }, + ], + }, + }) + t.ok(validate({}), '{}') + t.ok(validate([]), '[]') + t.ok(validate([[]]), '[[]]') + t.ok(validate([[[]]]), '[[[]]]') + t.notOk(validate([1]), '[1]') + t.ok(validate([{}]), '[{}]') + }) + + t.end() +})