From 841e34b320a95c30bae650cf9301c6df82f725ec Mon Sep 17 00:00:00 2001 From: Morgan Scout <256248948+morgan-coded@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:39:35 -0500 Subject: [PATCH 1/2] fix percent-encoded refs for ajv validation --- index.js | 4 +- lib/validator.js | 17 ++++- test/issue-740.test.js | 122 +++++++++++++++++++++++++++++++++++ test/standalone-mode.test.js | 30 +++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 test/issue-740.test.js diff --git a/index.js b/index.js index cf1c4c72..b5d59717 100644 --- a/index.js +++ b/index.js @@ -1151,7 +1151,7 @@ function buildOneOf (context, location, input) { } const nestedResult = buildValue(context, mergedLocation, input) - const schemaRef = optionLocation.getSchemaRef() + const schemaRef = Validator.encodeRefFragment(optionLocation.getSchemaRef()) code += ` ${index === 0 ? 'if' : 'else if'}(validator.validate("${schemaRef}", ${input})) { @@ -1184,7 +1184,7 @@ function buildIfThenElse (context, location, input) { ) const ifLocation = location.getPropertyLocation('if') - const ifSchemaRef = ifLocation.getSchemaRef() + const ifSchemaRef = Validator.encodeRefFragment(ifLocation.getSchemaRef()) const thenLocation = location.getPropertyLocation('then') let thenMergedSchemaId = context.mergedSchemasIds.get(thenSchema) diff --git a/lib/validator.js b/lib/validator.js index 77bbd101..d15bba51 100644 --- a/lib/validator.js +++ b/lib/validator.js @@ -6,6 +6,17 @@ const ajvFormats = require('ajv-formats') const clone = require('rfdc')({ proto: true }) class Validator { + // Ajv percent-decodes JSON Pointer URI fragments before looking up schema + // keys, while fast-json-stringify's ref resolver and code generator treat + // schema keys literally. Escaping '%' as '%25' in the fragment makes Ajv's + // decoding return the literal key, so both sides resolve the same schema. + // See https://github.com/fastify/fast-json-stringify/issues/740 + static encodeRefFragment (ref) { + const hashIndex = ref.indexOf('#') + if (hashIndex === -1) return ref + return ref.slice(0, hashIndex) + ref.slice(hashIndex).replace(/%/g, '%25') + } + constructor (ajvOptions) { this.ajv = new Ajv({ ...ajvOptions, @@ -43,7 +54,7 @@ class Validator { const ajvSchema = clone(schema) this.convertSchemaToAjvFormat(ajvSchema) this.ajv.addSchema(ajvSchema, schemaKey) - this._ajvSchemas[schemaKey] = schema + this._ajvSchemas[schemaKey] = ajvSchema } } @@ -59,6 +70,10 @@ class Validator { convertSchemaToAjvFormat (schema) { if (schema === null) return + if (typeof schema.$ref === 'string' && schema.$ref.includes('%')) { + schema.$ref = Validator.encodeRefFragment(schema.$ref) + } + if (schema.type === 'string') { schema.fjs_type = 'string' schema.type = ['string', 'object'] diff --git a/test/issue-740.test.js b/test/issue-740.test.js new file mode 100644 index 00000000..b1900d6a --- /dev/null +++ b/test/issue-740.test.js @@ -0,0 +1,122 @@ +'use strict' + +const { test, after } = require('node:test') +const fs = require('fs') +const path = require('path') +const build = require('..') + +process.env.TZ = 'UTC' + +// https://github.com/fastify/fast-json-stringify/issues/740 +// Schema keys containing percent-encoded sequences (e.g. '%3C') are resolved +// literally by fast-json-stringify, but the refs handed to ajv were not +// re-encoded, so ajv percent-decoded them and failed to find the keys. + +function buildIssueSchema (composition) { + return { + title: 'object with $ref', + definitions: { + 'Some%3Cloremipsum%3E': { + additionalProperties: composition, + type: 'object' + } + }, + type: 'object', + properties: { + obj: { + $ref: '#/definitions/Some%3Cloremipsum%3E' + } + } + } +} + +const oneOfComposition = { + oneOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'object' }, + { type: 'null' } + ] +} + +test('ref to a percent-encoded definition key with oneOf', (t) => { + t.plan(1) + + const stringify = build(buildIssueSchema(oneOfComposition)) + const output = stringify({ obj: { str: 'test' } }) + + t.assert.equal(output, '{"obj":{"str":"test"}}') +}) + +test('ref to a percent-encoded definition key with anyOf', (t) => { + t.plan(1) + + const stringify = build(buildIssueSchema({ + anyOf: [ + { type: 'string' }, + { type: 'number' } + ] + })) + const output = stringify({ obj: { str: 'test' } }) + + t.assert.equal(output, '{"obj":{"str":"test"}}') +}) + +test('ref to a percent-encoded definition key with if/then/else', (t) => { + t.plan(2) + + const stringify = build({ + definitions: { + 'Some%3Cloremipsum%3E': { + type: 'object', + properties: { kind: { type: 'string' } }, + if: { type: 'object', properties: { kind: { const: 'a' } } }, + then: { type: 'object', properties: { kind: { type: 'string' }, a: { type: 'string' } } }, + else: { type: 'object', properties: { kind: { type: 'string' }, b: { type: 'string' } } } + } + }, + type: 'object', + properties: { + obj: { $ref: '#/definitions/Some%3Cloremipsum%3E' } + } + }) + + t.assert.equal(stringify({ obj: { kind: 'a', a: 'x' } }), '{"obj":{"kind":"a","a":"x"}}') + t.assert.equal(stringify({ obj: { kind: 'c', b: 'y' } }), '{"obj":{"kind":"c","b":"y"}}') +}) + +test('property name containing a percent character with oneOf', (t) => { + t.plan(2) + + const stringify = build({ + type: 'object', + properties: { + 'weird%name': { + oneOf: [ + { type: 'string' }, + { type: 'number' } + ] + } + } + }) + + t.assert.equal(stringify({ 'weird%name': 'value' }), '{"weird%name":"value"}') + t.assert.equal(stringify({ 'weird%name': 42 }), '{"weird%name":42}') +}) + +test('ref to a percent-encoded definition key with oneOf in standalone mode', async (t) => { + t.plan(1) + + const code = build(buildIssueSchema(oneOfComposition), { mode: 'standalone' }) + + const destination = path.resolve('test/fixtures', 'standalone-issue-740.js') + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + await fs.promises.writeFile(destination, code) + const standalone = require(destination) + + t.assert.equal(standalone({ obj: { str: 'test' } }), '{"obj":{"str":"test"}}') +}) diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js index 528d05fa..2b348a5b 100644 --- a/test/standalone-mode.test.js +++ b/test/standalone-mode.test.js @@ -217,3 +217,33 @@ test('no need to keep external schemas once compiled - with oneOf validator', as t.assert.equal(stringify({ oneOfSchema: { baz: 5 } }), '{"oneOfSchema":{"baz":5}}') t.assert.equal(stringify({ oneOfSchema: { bar: 'foo' } }), '{"oneOfSchema":{"bar":"foo"}}') }) + +test('standalone serializer validates toJSON objects like the inline serializer', async (t) => { + t.plan(1) + + const schema = { + type: 'object', + properties: { + val: { + oneOf: [ + { type: 'string', format: 'date-time' }, + { type: 'number' } + ] + } + } + } + + const code = fjs(schema, { mode: 'standalone' }) + + const destination = path.resolve(tmpDir, 'standalone-tojson-oneof.js') + + after(async () => { + await fs.promises.rm(destination, { force: true }) + }) + + await fs.promises.writeFile(destination, code) + const stringify = require(destination) + + const date = new Date(1749556800000) + t.assert.equal(stringify({ val: date }), fjs(schema)({ val: date })) +}) From fa3832014c731f6c3c4b82bf0e42f8bb82dcf065 Mon Sep 17 00:00:00 2001 From: Morgan Scout <256248948+morgan-coded@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:40:09 -0500 Subject: [PATCH 2/2] test: scope standalone cleanup and drop TZ override --- test/issue-740.test.js | 17 +++++++++++------ test/standalone-mode.test.js | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/test/issue-740.test.js b/test/issue-740.test.js index b1900d6a..a35db099 100644 --- a/test/issue-740.test.js +++ b/test/issue-740.test.js @@ -1,12 +1,11 @@ 'use strict' -const { test, after } = require('node:test') +const { test } = require('node:test') const fs = require('fs') +const os = require('os') const path = require('path') const build = require('..') -process.env.TZ = 'UTC' - // https://github.com/fastify/fast-json-stringify/issues/740 // Schema keys containing percent-encoded sequences (e.g. '%3C') are resolved // literally by fast-json-stringify, but the refs handed to ajv were not @@ -109,12 +108,18 @@ test('ref to a percent-encoded definition key with oneOf in standalone mode', as const code = build(buildIssueSchema(oneOfComposition), { mode: 'standalone' }) - const destination = path.resolve('test/fixtures', 'standalone-issue-740.js') + // The standalone output does `require('fast-json-stringify/lib/...')`, so the + // generated file must live where that package resolves. Use a per-run temp + // dir and link node_modules into it so nothing is written into the repo tree. + const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'fjs-issue-740-')) - after(async () => { - await fs.promises.rm(destination, { force: true }) + t.after(async () => { + await fs.promises.rm(dir, { recursive: true, force: true }) }) + await fs.promises.symlink(path.resolve(__dirname, '..', 'node_modules'), path.join(dir, 'node_modules'), 'junction') + const destination = path.join(dir, 'standalone-issue-740.js') + await fs.promises.writeFile(destination, code) const standalone = require(destination) diff --git a/test/standalone-mode.test.js b/test/standalone-mode.test.js index 2b348a5b..9da82474 100644 --- a/test/standalone-mode.test.js +++ b/test/standalone-mode.test.js @@ -237,7 +237,7 @@ test('standalone serializer validates toJSON objects like the inline serializer' const destination = path.resolve(tmpDir, 'standalone-tojson-oneof.js') - after(async () => { + t.after(async () => { await fs.promises.rm(destination, { force: true }) })