From e629094b0530ec669fe6d846a9134ab2de305132 Mon Sep 17 00:00:00 2001 From: Nikita Skovoroda Date: Tue, 8 Aug 2023 21:42:23 +0300 Subject: [PATCH] feat: don't ensure full top-level validation in refs --- src/compile.js | 42 ++++---- test/regressions/strong-refs.js | 183 ++++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+), 18 deletions(-) diff --git a/src/compile.js b/src/compile.js index a257819..f0f57d1 100644 --- a/src/compile.js +++ b/src/compile.js @@ -1184,15 +1184,17 @@ 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 } - if (sub.type) { - // This could be done better, but for now we check only the direct type in the $ref - const type = Array.isArray(sub.type) ? sub.type : [sub.type] - evaluateDelta({ type }) - if (requireValidation) { - // If validation is required, then $ref is guranteed to validate all items and properties - if (type.includes('array')) evaluateDelta({ items: Infinity }) - if (type.includes('object')) evaluateDelta({ properties: [true] }) - } + // 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 }) } return applyRef(compileSub(sub, subRoot, path), { path: ['$ref'] }) }) @@ -1297,18 +1299,22 @@ const compileSchema = (schema, root, opts, scope, basePathRoot = '') => { } else if (!schemaPath.includes('not')) { // '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. - if (!stat.type) enforceValidation('type') - if (typeApplicable('array') && stat.items !== Infinity) - enforceValidation(node.items ? 'additionalItems or unevaluatedItems' : 'items rule') - if (typeApplicable('object') && !stat.properties.includes(true)) - enforceValidation('additionalProperties or unevaluatedProperties') + const isRefTop = schema !== root && node === schema // We are at the top-level of an opaque ref inside the schema object + if (!isRefTop) { + if (!stat.type) enforceValidation('type') + // This can't be true for top-level schemas, only references with #/... + if (typeApplicable('array') && stat.items !== Infinity) + enforceValidation(node.items ? 'additionalItems or unevaluatedItems' : 'items rule') + if (typeApplicable('object') && !stat.properties.includes(true)) + enforceValidation('additionalProperties or unevaluatedProperties') + if (!stat.fullstring && requireStringValidation) { + const stringWarning = 'pattern, format or contentSchema should be specified for strings' + fail(`[requireStringValidation] ${stringWarning}, use pattern: ^[\\s\\S]*$ to opt-out`) + } + } if (typeof node.propertyNames !== 'object') for (const sub of ['additionalProperties', 'unevaluatedProperties']) if (node[sub]) enforceValidation(`wild-card ${sub}`, 'requires propertyNames') - if (!stat.fullstring && requireStringValidation) { - const stringWarning = 'pattern, format or contentSchema should be specified for strings' - fail(`[requireStringValidation] ${stringWarning}, use pattern: ^[\\s\\S]*$ to opt-out`) - } } if (node.properties && !node.required) enforceValidation('if properties is used, required') enforce(unused.size === 0 || allowUnusedKeywords, 'Unprocessed keywords:', [...unused]) diff --git a/test/regressions/strong-refs.js b/test/regressions/strong-refs.js index b10b6c1..351f6be 100644 --- a/test/regressions/strong-refs.js +++ b/test/regressions/strong-refs.js @@ -106,3 +106,186 @@ tape('cyclic ref passes if fully covers the object', (t) => { t.end() }) + +tape('partially validating local ref can be used (objects)', (t) => { + const propTwoBare = { properties: { two: { const: 2 } }, required: ['two'] } + const propTwoFull = { ...propTwoBare, type: 'object' } + const compile = schema => validator( + { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...schema, + }, + { mode: 'strong' } + ) + + t.throws(() => compile(propTwoBare), /\[requireValidation\] type should be specified/) + t.throws(() => compile(propTwoFull), /\[requireValidation\].*Properties should be specified/) + + t.throws(() => { + compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoBare, + }, + // type: 'object', + unevaluatedProperties: false, + }, + { mode: 'strong' } + ) + }) + t.throws(() => { + compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoBare, + }, + type: 'object', + // unevaluatedProperties: false, + }, + { mode: 'strong' } + ) + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoBare, + }, + type: 'object', + unevaluatedProperties: false, + } + ) + t.notOk(validate({}), '{}') + t.ok(validate({ two: 2 }), '{two:2}') + t.notOk(validate({ three: 2 }), '{three:2}') + t.notOk(validate({ two: 3 }), '{two:3}') + t.notOk(validate({ two: 2, three: 3 }), '{two:2, three:3}') + t.notOk(validate({ two: 2, one: 1 }), '{two:2, one:1}') + t.notOk(validate({ one: 1 }), '{one:1}') + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoFull, + }, + unevaluatedProperties: false, + } + ) + t.notOk(validate({}), '{}') + t.ok(validate({ two: 2 }), '{two:2}') + t.notOk(validate({ three: 2 }), '{three:2}') + t.notOk(validate({ two: 3 }), '{two:3}') + t.notOk(validate({ two: 2, three: 3 }), '{two:2, three:3}') + t.notOk(validate({ two: 2, one: 1 }), '{two:2, one:1}') + t.notOk(validate({ one: 1 }), '{one:1}') + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoBare, + }, + required: [], + properties: { + one: { const: 1 }, + }, + type: 'object', + unevaluatedProperties: false, + } + ) + t.notOk(validate({}), '{}') + t.ok(validate({ two: 2 }), '{two:2}') + t.notOk(validate({ three: 2 }), '{three:2}') + t.notOk(validate({ two: 3 }), '{two:3}') + t.notOk(validate({ two: 2, three: 3 }), '{two:2, three:3}') + t.ok(validate({ two: 2, one: 1 }), '{two:2, one:1}') + t.notOk(validate({ one: 1 }), '{one:1}') + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/propTwo', + $defs: { + propTwo: propTwoFull, + }, + required: [], + properties: { + one: { const: 1 }, + }, + unevaluatedProperties: false, + } + ) + t.notOk(validate({}), '{}') + t.ok(validate({ two: 2 }), '{two:2}') + t.notOk(validate({ three: 2 }), '{three:2}') + t.notOk(validate({ two: 3 }), '{two:3}') + t.notOk(validate({ two: 2, three: 3 }), '{two:2, three:3}') + t.ok(validate({ two: 2, one: 1 }), '{two:2, one:1}') + t.notOk(validate({ one: 1 }), '{one:1}') + }) + + t.end() +}) + + +tape('partially validating local ref can be used (strings)', (t) => { + const stringDateBare = { format: 'date' } + const stringDateFull = { ...stringDateBare, type: 'string' } + const compile = schema => validator( + { + $schema: 'https://json-schema.org/draft/2020-12/schema', + ...schema, + }, + { mode: 'strong' } + ) + + t.throws(() => compile(stringDateBare), /\[requireValidation\] type should be specified/) + t.doesNotThrow(() => compile(stringDateFull)) + + t.throws(() => { + compile( + { + $ref: '#/$defs/stringDate', + $defs: { + stringDate: stringDateBare, + }, + // type: 'string', + }, + { mode: 'strong' } + ) + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/stringDate', + $defs: { + stringDate: stringDateBare, + }, + type: 'string', + } + ) + t.notOk(validate(''), '""') + t.notOk(validate('abc'), '"abc"') + t.ok(validate('2000-01-01'), '"2000-01-01"') + }) + t.doesNotThrow(() => { + const validate = compile( + { + $ref: '#/$defs/stringDate', + $defs: { + stringDate: stringDateFull, + }, + } + ) + t.notOk(validate(''), '""') + t.notOk(validate('abc'), '"abc"') + t.ok(validate('2000-01-01'), '"2000-01-01"') + }) + t.end() +})