From 6a0acc8ed7bbbefd892c00599e0c7abf232469e9 Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Sun, 13 Sep 2026 13:28:03 +0200 Subject: [PATCH 1/2] fix: escape property names in the schema refs the generated code validates against --- index.js | 14 +++++++------- lib/location.js | 4 +++- test/property-names.test.js | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 test/property-names.test.js diff --git a/index.js b/index.js index 3a02da13..e3be8567 100644 --- a/index.js +++ b/index.js @@ -700,7 +700,7 @@ function buildArray (context, location, input) { functionCode += ` if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} if (!Array.isArray(obj)) { - throw new TypeError(\`The value of '${schemaRef}' does not match schema definition.\`) + throw new TypeError(${JSON.stringify(`The value of '${schemaRef}' does not match schema definition.`)}) } const arrayLength = obj.length ` @@ -785,7 +785,7 @@ function buildArray (context, location, input) { if (${objVar} === null) { json += ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_ARRAY'} } else if (!Array.isArray(${objVar})) { - throw new TypeError(\`The value of '${safeSchemaRef}' does not match schema definition.\`) + throw new TypeError(${JSON.stringify(`The value of '${safeSchemaRef}' does not match schema definition.`)}) } else { const arrayLength_${objVar} = ${objVar}.length ` @@ -987,7 +987,7 @@ function buildMultiTypeSerializer (context, location, input) { } }) code += ` - else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + else throw new TypeError(${JSON.stringify(`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.`)}) ` return code @@ -1228,14 +1228,14 @@ function buildOneOf (context, location, input) { context.validatorSchemaRefs.add(schemaRef) code += ` - ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) { + ${index === 0 ? 'if' : 'else if'}(validator.validate(${JSON.stringify(schemaRef)}, ${input})) { ${nestedResult} } ` } code += ` - else throw new TypeError(\`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.\`) + else throw new TypeError(${JSON.stringify(`The value of '${getSafeSchemaRef(context, location)}' does not match schema definition.`)}) ` return code @@ -1278,7 +1278,7 @@ function buildIfThenElse (context, location, input) { if (!elseSchema) { return ` - if (validator.validate("${ifSchemaRef}", ${input})) { + if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) { ${buildValue(context, thenMergedLocation, input)} } else { ${buildValue(context, rootLocation, input)} @@ -1302,7 +1302,7 @@ function buildIfThenElse (context, location, input) { } return ` - if (validator.validate("${ifSchemaRef}", ${input})) { + if (validator.validate(${JSON.stringify(ifSchemaRef)}, ${input})) { ${buildValue(context, thenMergedLocation, input)} } else { ${buildValue(context, elseMergedLocation, input)} diff --git a/lib/location.js b/lib/location.js index 0d9acb2d..b7d81220 100644 --- a/lib/location.js +++ b/lib/location.js @@ -8,10 +8,12 @@ class Location { } getPropertyLocation (propertyName) { + // a JSON pointer segment, so a property name with a / or a ~ still points at the property + const segment = String(propertyName).replace(/~/g, '~0').replace(/\//g, '~1') const propertyLocation = new Location( this.schema[propertyName], this.schemaId, - this.jsonPointer + '/' + propertyName + this.jsonPointer + '/' + segment ) return propertyLocation } diff --git a/test/property-names.test.js b/test/property-names.test.js new file mode 100644 index 00000000..d50eb180 --- /dev/null +++ b/test/property-names.test.js @@ -0,0 +1,25 @@ +'use strict' + +const { test } = require('node:test') +const build = require('..') + +// the schema ref of a property with anyOf, oneOf or if goes into the generated code as a +// string and into a JSON pointer, so a name that needs escaping in either broke the build +const names = ['new\nline', 'q"uote', 'back\\slash', 'a/b', 'a~b', 'a%b', 'a#b', 'a b', 'tick`', 'dollar$' + '{x}', '\\u', "single'quote"] +const branches = { + oneOf: { oneOf: [{ type: 'string' }, { type: 'object', properties: { k: { type: 'integer' } }, required: ['k'] }] }, + anyOf: { anyOf: [{ type: 'string' }, { type: 'object', properties: { k: { type: 'integer' } }, required: ['k'] }] }, + if: { if: { type: 'string' }, then: { type: 'string' }, else: { type: 'integer' } } +} + +for (const name of names) { + for (const [keyword, subschema] of Object.entries(branches)) { + test(`a property named ${JSON.stringify(name)} with ${keyword}`, (t) => { + t.plan(2) + + const stringify = build({ type: 'object', properties: { [name]: subschema } }) + t.assert.equal(stringify({ [name]: 'v' }), JSON.stringify({ [name]: 'v' })) + t.assert.throws(() => stringify({ [name]: {} }), keyword === 'if' ? Error : new TypeError(`The value of '#/properties/${name.replace(/~/g, '~0').replace(/\//g, '~1')}' does not match schema definition.`)) + }) + } +} From d90edc0885d0f5cac25e08788864bfebb71e3886 Mon Sep 17 00:00:00 2001 From: Nigro Simone Date: Sun, 13 Sep 2026 13:32:15 +0200 Subject: [PATCH 2/2] fix: escape property names on the comment lines of the generated code too --- index.js | 12 ++++++++++-- test/property-names.test.js | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index e3be8567..e6f1bac2 100644 --- a/index.js +++ b/index.js @@ -94,6 +94,14 @@ function getSchemaId (schema, rootSchemaId) { return rootSchemaId } +// a schema ref on a comment line of the generated code: a line terminator in a property name +// would end the comment and turn the rest of the name into code. JSON.stringify covers \n and +// \r, the two separators JS also treats as line terminators are escaped by hand +const LINE_SEPARATORS = /[\u2028\u2029]/g +function asComment (schemaRef) { + return JSON.stringify(schemaRef).replace(LINE_SEPARATORS, (c) => '\\u' + c.charCodeAt(0).toString(16)) +} + function getSafeSchemaRef (context, location) { let schemaRef = location.getSchemaRef() || '' if (schemaRef.startsWith(context.rootSchemaId)) { @@ -633,7 +641,7 @@ function buildObject (context, location, input) { const schemaRef = getSafeSchemaRef(context, location) const functionCode = ` - // ${schemaRef} + // ${asComment(schemaRef)} function ${functionName} (input) { const obj = ${toJSON('input')} if (obj === null) return ${nullable ? 'JSON_STR_NULL' : 'JSON_STR_EMPTY_OBJECT'} @@ -693,7 +701,7 @@ function buildArray (context, location, input) { let functionCode = ` function ${functionName} (obj) { - // ${schemaRef} + // ${asComment(schemaRef)} let json = '' ` diff --git a/test/property-names.test.js b/test/property-names.test.js index d50eb180..df81e98e 100644 --- a/test/property-names.test.js +++ b/test/property-names.test.js @@ -23,3 +23,19 @@ for (const name of names) { }) } } + +// the ref also goes on a comment line of the generated code, above the function of a nested +// object or array, where a line terminator in the name ends the comment early +for (const name of ['new' + String.fromCharCode(10) + 'line', 'line' + String.fromCharCode(0x2028) + 'sep', 'para' + String.fromCharCode(0x2029) + 'graph']) { + test(`a property named ${JSON.stringify(name)} with a nested object and array`, (t) => { + t.plan(1) + + const stringify = build({ + type: 'object', + properties: { + [name]: { type: 'object', properties: { list: { type: 'array', items: { type: 'integer' } } }, nullable: true } + } + }) + t.assert.equal(stringify({ [name]: { list: [1] } }), JSON.stringify({ [name]: { list: [1] } })) + }) +}