Skip to content

Commit ff12c21

Browse files
authored
fix: escape JSON pointer tokens in generated paths (#22)
## Summary This PR fixes JSON Pointer path generation to be RFC 6901-compliant when object keys contain reserved characters. Previously, generated patch paths used raw object keys, which produced invalid/ambiguous pointers for keys containing `/`, `~`, or empty string keys. This affected generated JSON Patch operations (`path` and `from`) and could break downstream patch application. Closes #21. cc @BrianHuf ## What changed ### Added JSON Pointer token escaping in path construction: - `~` -> `~0` - `/` -> `~1` - Centralized object-key path construction through a helper so add/replace/remove paths are consistently escaped. - Kept implementation dependency-free (no new packages added). ### Tests added/updated - Escaping for keys with `/` and `~` - RFC 6901 escape ordering case (`~1` token -> `~01`) - Empty-string keys (`/` and nested trailing `/`) - Non-reserved characters remain unescaped - Escaped paths for `move` operations (`from` and `path`) - `maxDepth` behavior with slash-containing keys
1 parent e54460f commit ff12c21

2 files changed

Lines changed: 221 additions & 4 deletions

File tree

src/index.spec.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,142 @@ describe('generateJSONPatch', () => {
198198
},
199199
],
200200
],
201+
[
202+
'escapes object keys in JSON Pointer paths',
203+
{
204+
data: {
205+
'a/b': 1,
206+
'c/d': {
207+
'/e/f': 3,
208+
},
209+
},
210+
},
211+
{
212+
data: {
213+
'c/d': {
214+
'/e/f': 2,
215+
},
216+
'/g/h': 3,
217+
'i~j/k': 4,
218+
},
219+
},
220+
[
221+
{
222+
op: 'replace',
223+
path: '/data/c~1d/~1e~1f',
224+
value: 2,
225+
},
226+
{
227+
op: 'add',
228+
path: '/data/~1g~1h',
229+
value: 3,
230+
},
231+
{
232+
op: 'add',
233+
path: '/data/i~0j~1k',
234+
value: 4,
235+
},
236+
{
237+
op: 'remove',
238+
path: '/data/a~1b',
239+
},
240+
],
241+
],
242+
[
243+
'preserves RFC6901 escape ordering for tokens containing "~1"',
244+
{
245+
data: {
246+
'~1': 10,
247+
},
248+
},
249+
{
250+
data: {
251+
'~1': 11,
252+
},
253+
},
254+
[
255+
{
256+
op: 'replace',
257+
path: '/data/~01',
258+
value: 11,
259+
},
260+
],
261+
],
262+
[
263+
'supports empty string object keys',
264+
{
265+
'': 1,
266+
nested: {
267+
'': 1,
268+
},
269+
},
270+
{
271+
'': 2,
272+
nested: {
273+
'': 2,
274+
},
275+
},
276+
[
277+
{
278+
op: 'replace',
279+
path: '/',
280+
value: 2,
281+
},
282+
{
283+
op: 'replace',
284+
path: '/nested/',
285+
value: 2,
286+
},
287+
],
288+
],
289+
[
290+
'does not escape non-reserved JSON Pointer token characters',
291+
{
292+
data: {},
293+
},
294+
{
295+
data: {
296+
'c%d': 2,
297+
'e^f': 3,
298+
'g|h': 4,
299+
'i\\j': 5,
300+
'k"l': 6,
301+
' ': 7,
302+
},
303+
},
304+
[
305+
{
306+
op: 'add',
307+
path: '/data/c%d',
308+
value: 2,
309+
},
310+
{
311+
op: 'add',
312+
path: '/data/e^f',
313+
value: 3,
314+
},
315+
{
316+
op: 'add',
317+
path: '/data/g|h',
318+
value: 4,
319+
},
320+
{
321+
op: 'add',
322+
path: '/data/i\\j',
323+
value: 5,
324+
},
325+
{
326+
op: 'add',
327+
path: '/data/k"l',
328+
value: 6,
329+
},
330+
{
331+
op: 'add',
332+
path: '/data/ ',
333+
value: 7,
334+
},
335+
],
336+
],
201337
];
202338
tests.forEach(([testTitle, beforeJson, afterJson, patch]) => {
203339
describe(testTitle, () => {
@@ -557,6 +693,32 @@ describe('generateJSONPatch', () => {
557693
{ op: 'move', from: '/engine/3', path: '/engine/0' },
558694
]);
559695
});
696+
697+
it('escapes move operation paths for arrays nested under escaped keys', () => {
698+
const before = {
699+
'a/b': [{ id: 1 }, { id: 2 }],
700+
};
701+
const after = {
702+
'a/b': [{ id: 2 }, { id: 1 }],
703+
};
704+
705+
const patch = generateJSONPatch(before, after, {
706+
objectHash: function (obj: any) {
707+
return `${obj.id}`;
708+
},
709+
});
710+
711+
assert.deepStrictEqual(patch, [
712+
{
713+
op: 'move',
714+
from: '/a~1b/1',
715+
path: '/a~1b/0',
716+
},
717+
]);
718+
719+
const patched = doPatch(before, patch);
720+
assert.deepStrictEqual(patched, after);
721+
});
560722
});
561723

562724
describe('with property filter', () => {
@@ -829,6 +991,38 @@ describe('generateJSONPatch', () => {
829991
});
830992
assert.deepStrictEqual(patch, []);
831993
});
994+
995+
it('does not overcount depth when keys contain slashes', () => {
996+
const beforeWithSlashKey = {
997+
'a/b': {
998+
child: {
999+
value: 'before',
1000+
},
1001+
stable: true,
1002+
},
1003+
};
1004+
const afterWithSlashKey = {
1005+
'a/b': {
1006+
child: {
1007+
value: 'after',
1008+
},
1009+
stable: true,
1010+
},
1011+
};
1012+
1013+
const patch = generateJSONPatch(beforeWithSlashKey, afterWithSlashKey, {
1014+
maxDepth: 3,
1015+
});
1016+
assert.deepStrictEqual(patch, [
1017+
{
1018+
op: 'replace',
1019+
path: '/a~1b/child',
1020+
value: {
1021+
value: 'after',
1022+
},
1023+
},
1024+
]);
1025+
});
8321026
});
8331027
});
8341028

src/index.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,7 @@ export function generateJSONPatch(
188188
)
189189
continue;
190190

191-
let newPath =
192-
isArrayAtTop && path === '' ? `/${rightKey}` : `${path}/${rightKey}`;
191+
const newPath = buildPath(path, rightKey);
193192
const leftValue = leftJsonValue[rightKey];
194193
const rightValue = rightJsonValue[rightKey];
195194

@@ -228,8 +227,7 @@ export function generateJSONPatch(
228227
continue;
229228

230229
if (!Object.prototype.hasOwnProperty.call(rightJsonValue, leftKey)) {
231-
let newPath =
232-
isArrayAtTop && path === '' ? `/${leftKey}` : `${path}/${leftKey}`;
230+
const newPath = buildPath(path, leftKey);
233231
patch.push({ op: 'remove', path: newPath });
234232
}
235233
}
@@ -240,6 +238,31 @@ export function generateJSONPatch(
240238
return [...patch];
241239
}
242240

241+
const tokenEscapedTildeRegExp = /~/g;
242+
const tokenEscapedSlashRegExp = /\//g;
243+
244+
/**
245+
* Escapes a JSON Pointer reference token per RFC 6901.
246+
* Order matters: "~" must be replaced before "/" to preserve "~1" sequences.
247+
*/
248+
function escapeReferenceToken(token: string): string {
249+
return token
250+
.replace(tokenEscapedTildeRegExp, '~0')
251+
.replace(tokenEscapedSlashRegExp, '~1');
252+
}
253+
254+
/**
255+
* Builds an RFC 6901-compliant JSON Pointer path by escaping a key token
256+
* and appending it to the current path.
257+
*/
258+
function buildPath(path: string, key: string): string {
259+
const escapedKey = escapeReferenceToken(key);
260+
if (path === '') {
261+
return `/${escapedKey}`;
262+
}
263+
return `${path}/${escapedKey}`;
264+
}
265+
243266
function isPrimitiveValue(value: JsonValue): value is JsonValue {
244267
return (
245268
value === undefined ||

0 commit comments

Comments
 (0)