From 345b215bc38fd64273ba2c5b0d7f47b895e782c6 Mon Sep 17 00:00:00 2001 From: Marco Link Date: Wed, 11 Feb 2026 20:00:35 +0100 Subject: [PATCH 1/2] fix: escape JSON pointer tokens in generated paths --- src/index.spec.ts | 194 ++++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 23 +++++- 2 files changed, 213 insertions(+), 4 deletions(-) diff --git a/src/index.spec.ts b/src/index.spec.ts index 5603bfe..659bff1 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -198,6 +198,142 @@ describe('generateJSONPatch', () => { }, ], ], + [ + 'escapes object keys in JSON Pointer paths', + { + data: { + 'a/b': 1, + 'c/d': { + '/e/f': 3, + }, + }, + }, + { + data: { + 'c/d': { + '/e/f': 2, + }, + '/g/h': 3, + 'i~j/k': 4, + }, + }, + [ + { + op: 'replace', + path: '/data/c~1d/~1e~1f', + value: 2, + }, + { + op: 'add', + path: '/data/~1g~1h', + value: 3, + }, + { + op: 'add', + path: '/data/i~0j~1k', + value: 4, + }, + { + op: 'remove', + path: '/data/a~1b', + }, + ], + ], + [ + 'preserves RFC6901 escape ordering for tokens containing "~1"', + { + data: { + '~1': 10, + }, + }, + { + data: { + '~1': 11, + }, + }, + [ + { + op: 'replace', + path: '/data/~01', + value: 11, + }, + ], + ], + [ + 'supports empty string object keys', + { + '': 1, + nested: { + '': 1, + }, + }, + { + '': 2, + nested: { + '': 2, + }, + }, + [ + { + op: 'replace', + path: '/', + value: 2, + }, + { + op: 'replace', + path: '/nested/', + value: 2, + }, + ], + ], + [ + 'does not escape non-reserved JSON Pointer token characters', + { + data: {}, + }, + { + data: { + 'c%d': 2, + 'e^f': 3, + 'g|h': 4, + 'i\\j': 5, + 'k"l': 6, + ' ': 7, + }, + }, + [ + { + op: 'add', + path: '/data/c%d', + value: 2, + }, + { + op: 'add', + path: '/data/e^f', + value: 3, + }, + { + op: 'add', + path: '/data/g|h', + value: 4, + }, + { + op: 'add', + path: '/data/i\\j', + value: 5, + }, + { + op: 'add', + path: '/data/k"l', + value: 6, + }, + { + op: 'add', + path: '/data/ ', + value: 7, + }, + ], + ], ]; tests.forEach(([testTitle, beforeJson, afterJson, patch]) => { describe(testTitle, () => { @@ -557,6 +693,32 @@ describe('generateJSONPatch', () => { { op: 'move', from: '/engine/3', path: '/engine/0' }, ]); }); + + it('escapes move operation paths for arrays nested under escaped keys', () => { + const before = { + 'a/b': [{ id: 1 }, { id: 2 }], + }; + const after = { + 'a/b': [{ id: 2 }, { id: 1 }], + }; + + const patch = generateJSONPatch(before, after, { + objectHash: function (obj: any) { + return `${obj.id}`; + }, + }); + + assert.deepStrictEqual(patch, [ + { + op: 'move', + from: '/a~1b/1', + path: '/a~1b/0', + }, + ]); + + const patched = doPatch(before, patch); + assert.deepStrictEqual(patched, after); + }); }); describe('with property filter', () => { @@ -829,6 +991,38 @@ describe('generateJSONPatch', () => { }); assert.deepStrictEqual(patch, []); }); + + it('does not overcount depth when keys contain slashes', () => { + const beforeWithSlashKey = { + 'a/b': { + child: { + value: 'before', + }, + stable: true, + }, + }; + const afterWithSlashKey = { + 'a/b': { + child: { + value: 'after', + }, + stable: true, + }, + }; + + const patch = generateJSONPatch(beforeWithSlashKey, afterWithSlashKey, { + maxDepth: 3, + }); + assert.deepStrictEqual(patch, [ + { + op: 'replace', + path: '/a~1b/child', + value: { + value: 'after', + }, + }, + ]); + }); }); }); diff --git a/src/index.ts b/src/index.ts index 57c627b..bc72e84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -188,8 +188,7 @@ export function generateJSONPatch( ) continue; - let newPath = - isArrayAtTop && path === '' ? `/${rightKey}` : `${path}/${rightKey}`; + const newPath = buildPath(path, rightKey); const leftValue = leftJsonValue[rightKey]; const rightValue = rightJsonValue[rightKey]; @@ -228,8 +227,7 @@ export function generateJSONPatch( continue; if (!Object.prototype.hasOwnProperty.call(rightJsonValue, leftKey)) { - let newPath = - isArrayAtTop && path === '' ? `/${leftKey}` : `${path}/${leftKey}`; + const newPath = buildPath(path, leftKey); patch.push({ op: 'remove', path: newPath }); } } @@ -240,6 +238,23 @@ export function generateJSONPatch( return [...patch]; } +const tokenEscapedTildeRegExp = /~/g; +const tokenEscapedSlashRegExp = /\//g; + +function escapeReferenceToken(token: string): string { + return token + .replace(tokenEscapedTildeRegExp, '~0') + .replace(tokenEscapedSlashRegExp, '~1'); +} + +function buildPath(path: string, key: string): string { + const escapedKey = escapeReferenceToken(key); + if (path === '') { + return `/${escapedKey}`; + } + return `${path}/${escapedKey}`; +} + function isPrimitiveValue(value: JsonValue): value is JsonValue { return ( value === undefined || From 261184fc9b2fd78b3274cb49e462dfaac9a20413 Mon Sep 17 00:00:00 2001 From: Marco Link Date: Wed, 11 Feb 2026 20:08:59 +0100 Subject: [PATCH 2/2] docs: add JSDoc for RFC6901 pointer helpers --- src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/index.ts b/src/index.ts index bc72e84..a4380a8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,12 +241,20 @@ export function generateJSONPatch( const tokenEscapedTildeRegExp = /~/g; const tokenEscapedSlashRegExp = /\//g; +/** + * Escapes a JSON Pointer reference token per RFC 6901. + * Order matters: "~" must be replaced before "/" to preserve "~1" sequences. + */ function escapeReferenceToken(token: string): string { return token .replace(tokenEscapedTildeRegExp, '~0') .replace(tokenEscapedSlashRegExp, '~1'); } +/** + * Builds an RFC 6901-compliant JSON Pointer path by escaping a key token + * and appending it to the current path. + */ function buildPath(path: string, key: string): string { const escapedKey = escapeReferenceToken(key); if (path === '') {