diff --git a/lib/validator.js b/lib/validator.js index 77bbd101..7bec3394 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -43,7 +43,7 @@ class Validator { const ajvSchema = clone(schema) this.convertSchemaToAjvFormat(ajvSchema) this.ajv.addSchema(ajvSchema, schemaKey) - this._ajvSchemas[schemaKey] = schema + this._ajvSchemas[schemaKey] = ajvSchema } } @@ -70,6 +70,14 @@ class Validator { schema.fjs_type = 'string' schema.type.push('object') } + + // Ajv percent-decodes the $ref fragment before matching it against the + // definitions keys (see ajv's unescapeFragment), while our internal + // serializer resolves refs literally. Decode the definitions/$defs keys + // so that keys copied verbatim into $ref (e.g. 'Some%3Cloremipsum%3E') + // resolve consistently on both sides. + this.normalizeDefinitionsKeys(schema) + for (const property in schema) { if (typeof schema[property] === 'object') { this.convertSchemaToAjvFormat(schema[property]) @@ -77,6 +85,41 @@ class Validator { } } + normalizeDefinitionsKeys (schema) { + for (const containerKey of ['definitions', '$defs']) { + const defs = schema[containerKey] + if (defs === null || typeof defs !== 'object' || Array.isArray(defs)) { + continue + } + for (const key of Object.keys(defs)) { + let decoded + try { + decoded = decodeURIComponent(key) + } catch { + // Invalid or incomplete percent sequences: decode only the valid + // percent-encoded segments, leaving the rest untouched. + decoded = key.replace(/%[0-9a-fA-F]{2}/g, (segment) => { + try { + return decodeURIComponent(segment) + } catch { + return segment + } + }) + } + if (decoded === key) { + continue + } + // Skip when decoding would collide with an existing key, otherwise + // one of the two definitions would silently shadow the other. + if (Object.prototype.hasOwnProperty.call(defs, decoded)) { + continue + } + defs[decoded] = defs[key] + delete defs[key] + } + } + } + getState () { return { ajvOptions: this._ajvOptions, diff --git a/test/ref.test.js b/test/ref.test.js index 2f736445..1ae830d1 100644 --- a/test/ref.test.js +++ b/test/ref.test.js @@ -2075,3 +2075,76 @@ test('ref nested', (t) => { t.assert.doesNotThrow(() => JSON.parse(output)) t.assert.equal(output, '{"str":"test"}') }) + +test('ref internal - definitions key with percent-encoded characters (#740)', (t) => { + t.plan(2) + + const schema = { + title: 'object with $ref', + definitions: { + 'Some%3Cloremipsum%3E': { + additionalProperties: { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'object' }, + { type: 'null' } + ] + }, + type: 'object' + } + }, + type: 'object', + properties: { + obj: { + $ref: '#/definitions/Some%3Cloremipsum%3E' + } + } + } + + const object = { + obj: { + str: 'test' + } + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') +}) + +test('ref internal - $defs key with percent-encoded characters (#740)', (t) => { + t.plan(2) + + const schema = { + $defs: { + 'Some%3Cloremipsum%3E': { + type: 'object', + properties: { + str: { type: 'string' } + }, + required: ['str'] + } + }, + type: 'object', + properties: { + obj: { + $ref: '#/$defs/Some%3Cloremipsum%3E' + } + } + } + + const object = { + obj: { + str: 'test' + } + } + + const stringify = build(schema) + const output = stringify(object) + + t.assert.doesNotThrow(() => JSON.parse(output)) + t.assert.equal(output, '{"obj":{"str":"test"}}') +})