Skip to content

Commit 7f69155

Browse files
naman-contentstackclaude
andcommitted
fix(import): remap asset and entry UIDs containing regex metacharacters
The asset UID swap in lookupAssets regex-escaped the UID and then handed it to String.split, which matches literally. A UID holding a regex metacharacter never matched the serialized entry, so the source UID survived the swap and the CMA rejected the entry with "Asset(s) does not exists" - a message that points at the export rather than at the failed rewrite. Escaping now matches the encoding the search actually runs against (JSON), so UIDs with a backslash or a quote work as well, and a swap that changes nothing is recorded as unmatched rather than matched, which keeps a silent failure out of matched-asset-uids.json. updateUids carried the same escape-then-literal-split mismatch for entry UIDs in HTML RTE. It now builds a single alternation from every matching UID, longest first, so a UID that is a prefix of another cannot clobber it and a replacement value cannot be re-scanned by a later pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent d1aaa7e commit 7f69155

5 files changed

Lines changed: 239 additions & 15 deletions

File tree

‎.talismanrc‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ fileignoreconfig:
3939
checksum: a64a4d396eddd936a63b799eff58c5c6660b5dcaa3a310fd8b09a027932f1789
4040
- filename: packages/contentstack-migration/README.md
4141
checksum: e96006c1a948f766c88ae972b29582fa58eaf8184606bf011eebddc5a06cd7b6
42+
- filename: packages/contentstack-import/test/unit/utils/asset-helper.test.ts
43+
checksum: da1d476d0a7aaaaee8a355571d85facc5f4de081ede40c13fd263df3bfb5c521
4244
version: ""

‎packages/contentstack-import/src/utils/asset-helper.ts‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import Bluebird from 'bluebird';
22
import * as url from 'url';
33
import * as path from 'path';
4-
import { ContentstackClient, managementSDKClient, validateRegex, log } from '@contentstack/cli-utilities';
4+
import { ContentstackClient, managementSDKClient, log } from '@contentstack/cli-utilities';
55
import { ImportConfig } from '../types';
66
const debug = require('debug')('util:requests');
77
let _ = require('lodash');
@@ -10,7 +10,8 @@ let helper = require('./file-helper');
1010

1111
const MAX_RETRY_LIMIT = 5;
1212

13-
const escapeRegExp = (str: string) => str.replace(/[*+?^${}()|[\]\\]/g, '\\$&');
13+
// escapes a value the way it appears inside a JSON string, without the surrounding quotes
14+
const jsonEscape = (str: string) => JSON.stringify(str).slice(1, -1);
1415

1516
function validate(req: any) {
1617
if (typeof req !== 'object') {
@@ -293,9 +294,17 @@ export const lookupAssets = function (
293294
assetUids.forEach(function (assetUid: any) {
294295
let uid = mappedAssetUids[assetUid];
295296
if (typeof uid !== 'undefined') {
296-
const escapedAssetUid = assetUid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
297-
entry = entry.split(escapedAssetUid).join(uid);
298-
matchedUids.push(assetUid);
297+
// split() matches literally, so the UID must not be regex-escaped. It does need JSON
298+
// escaping though, since the search runs on the serialized entry (a UID containing a
299+
// backslash or a quote appears escaped there).
300+
const updatedEntry = entry.split(jsonEscape(assetUid)).join(jsonEscape(uid));
301+
if (updatedEntry !== entry) {
302+
entry = updatedEntry;
303+
matchedUids.push(assetUid);
304+
} else {
305+
log.debug(`Asset UID ${assetUid} had a mapping but no occurrence in entry ${data.entry?.uid}`);
306+
unmatchedUids.push(assetUid);
307+
}
299308
} else {
300309
unmatchedUids.push(assetUid);
301310
}

‎packages/contentstack-import/src/utils/entries-helper.ts‎

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -603,13 +603,9 @@ export const restoreJsonRteEntryRefs = (
603603
if (sourceStackEntry[element.uid].indexOf(uid) !== -1) return uid;
604604
});
605605
if (element.multiple && Array.isArray(entry[element.uid])) {
606-
for (let i = 0; i < matches.length; i++) {
607-
entry[element.uid] = entry[element.uid].map((el: string) => updateUids(el, matches[i], uidMapper));
608-
}
606+
entry[element.uid] = entry[element.uid].map((el: string) => updateUids(el, matches, uidMapper));
609607
} else {
610-
for (let i = 0; i < matches.length; i++) {
611-
entry[element.uid] = updateUids(entry[element.uid], matches[i], uidMapper);
612-
}
608+
entry[element.uid] = updateUids(entry[element.uid], matches, uidMapper);
613609
}
614610
}
615611
break;
@@ -619,10 +615,12 @@ export const restoreJsonRteEntryRefs = (
619615
return entry;
620616
};
621617

622-
function updateUids(str: string, match: string, uidMapper: Record<string, string>) {
623-
const sanitizedMatch = escapeRegExp(match);
624-
const replacement = uidMapper[match] ?? sanitizedMatch;
625-
return str.split(sanitizedMatch).join(replacement);
618+
function updateUids(str: string, matches: string[], uidMapper: Record<string, string>) {
619+
if (!matches.length) return str;
620+
// longest-first so a UID that's a prefix of another (entry.1 vs entry.10) never wins the match;
621+
// single regex pass so a replacement value can never itself get re-scanned/re-replaced
622+
const pattern = [...matches].sort((a, b) => b.length - a.length).map(escapeRegExp).join('|');
623+
return str.replace(new RegExp(pattern, 'g'), (match) => uidMapper[match] ?? match);
626624
}
627625

628626
function setDirtyTrue(jsonRteChild: any) {

‎packages/contentstack-import/test/unit/utils/asset-helper.test.ts‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,117 @@ describe('Asset Helper', () => {
426426
expect(result).to.exist;
427427
});
428428

429+
it('should remap JSON RTE asset UIDs containing regex special characters', () => {
430+
const data = {
431+
entry: {
432+
uid: 'entry1',
433+
json_rte: {
434+
children: [
435+
{
436+
type: 'reference',
437+
attrs: {
438+
type: 'asset',
439+
'asset-uid': 'safe_6.0_big_picture_sorrento_web',
440+
'asset-link': '/assets/safe_6.0_big_picture_sorrento_web'
441+
},
442+
children: [] as any
443+
}
444+
]
445+
}
446+
},
447+
content_type: {
448+
uid: 'ct1',
449+
schema: [
450+
{
451+
uid: 'json_rte',
452+
data_type: 'json',
453+
field_metadata: { rich_text_type: true }
454+
}
455+
]
456+
}
457+
};
458+
const mappedAssetUids = { 'safe_6.0_big_picture_sorrento_web': 'bltNewAssetUid' };
459+
460+
const result = lookupAssets(data, mappedAssetUids, {}, '/test/mapper', []);
461+
462+
expect(JSON.stringify(result)).to.not.include('safe_6.0_big_picture_sorrento_web');
463+
expect(JSON.stringify(result)).to.include('bltNewAssetUid');
464+
});
465+
466+
it('should remap asset UIDs for every regex special character', () => {
467+
const specialCharUids = [
468+
'asset.1',
469+
'asset*1',
470+
'asset+1',
471+
'asset?1',
472+
'asset^1',
473+
'asset$1',
474+
'asset{1}',
475+
'asset(1)',
476+
'asset|1',
477+
'asset[1]',
478+
'asset\\1'
479+
];
480+
481+
specialCharUids.forEach((assetUid, index) => {
482+
const mappedUid = `bltMapped${index}`;
483+
const data = {
484+
entry: {
485+
uid: 'entry1',
486+
json_rte: {
487+
children: [
488+
{
489+
type: 'reference',
490+
attrs: { type: 'asset', 'asset-uid': assetUid },
491+
children: [] as any
492+
}
493+
]
494+
}
495+
},
496+
content_type: {
497+
uid: 'ct1',
498+
schema: [
499+
{ uid: 'json_rte', data_type: 'json', field_metadata: { rich_text_type: true } }
500+
]
501+
}
502+
};
503+
504+
const result = lookupAssets(data, { [assetUid]: mappedUid }, {}, '/test/mapper', []);
505+
506+
expect(result.json_rte.children[0].attrs['asset-uid'], `failed for UID: ${assetUid}`).to.equal(mappedUid);
507+
});
508+
});
509+
510+
it('should record a special character asset UID as matched', () => {
511+
const writeFileStub = fileHelper.writeFile as unknown as sinon.SinonStub;
512+
const data = {
513+
entry: {
514+
uid: 'entry1',
515+
json_rte: {
516+
children: [
517+
{
518+
type: 'reference',
519+
attrs: { type: 'asset', 'asset-uid': 'asset.with.dots' },
520+
children: [] as any
521+
}
522+
]
523+
}
524+
},
525+
content_type: {
526+
uid: 'ct1',
527+
schema: [
528+
{ uid: 'json_rte', data_type: 'json', field_metadata: { rich_text_type: true } }
529+
]
530+
}
531+
};
532+
533+
lookupAssets(data, { 'asset.with.dots': 'bltNewAssetUid' }, {}, '/test/mapper', []);
534+
535+
const writtenPaths = writeFileStub.getCalls().map((call: any) => call.args[0]);
536+
expect(writtenPaths.some((p: string) => p.includes('matched-asset-uids.json'))).to.be.true;
537+
expect(writtenPaths.some((p: string) => p.includes('unmatched-asset-uids.json'))).to.be.false;
538+
});
539+
429540
it('should handle JSON custom fields with extensions', () => {
430541
const data = {
431542
entry: {

‎packages/contentstack-import/test/unit/utils/entries-helper.test.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,110 @@ describe('Entries Helper', () => {
384384
expect(restoreJsonRteEntryRefs).to.be.a('function');
385385
});
386386

387+
it('should remap entry UIDs containing regex special characters in HTML RTE', () => {
388+
const sourceUid = 'source.entry.1';
389+
const ctSchema = [
390+
{ uid: 'html_rte_field', data_type: 'text', field_metadata: { rich_text_type: true } }
391+
];
392+
const sourceStackEntry = {
393+
html_rte_field: `<p>ref</p><span data-uid="${sourceUid}"></span>`
394+
};
395+
// non-empty: restoreJsonRteEntryRefs skips falsy fields before restoring from source
396+
const entry = { html_rte_field: '<p></p>' };
397+
398+
const result = restoreJsonRteEntryRefs(entry, sourceStackEntry, ctSchema, {
399+
uidMapper: { [sourceUid]: 'bltTargetEntry1' },
400+
mappedAssetUids: {},
401+
mappedAssetUrls: {}
402+
});
403+
404+
expect(result.html_rte_field).to.not.include(sourceUid);
405+
expect(result.html_rte_field).to.include('bltTargetEntry1');
406+
});
407+
408+
it('should leave an unmapped special character entry UID untouched in HTML RTE', () => {
409+
const sourceUid = 'source.entry.1';
410+
const ctSchema = [
411+
{ uid: 'html_rte_field', data_type: 'text', field_metadata: { rich_text_type: true } }
412+
];
413+
const sourceStackEntry = {
414+
html_rte_field: `<span data-uid="${sourceUid}"></span>`
415+
};
416+
// non-empty: restoreJsonRteEntryRefs skips falsy fields before restoring from source
417+
const entry = { html_rte_field: '<p></p>' };
418+
419+
const result = restoreJsonRteEntryRefs(entry, sourceStackEntry, ctSchema, {
420+
uidMapper: { 'other.entry': 'bltOther' },
421+
mappedAssetUids: {},
422+
mappedAssetUrls: {}
423+
});
424+
425+
// no mapping applies, so the field must come through byte-for-byte, with no escape characters injected
426+
expect(result.html_rte_field).to.equal(sourceStackEntry.html_rte_field);
427+
});
428+
429+
it('should not let a UID that is a prefix of another UID clobber the longer one in HTML RTE', () => {
430+
const ctSchema = [
431+
{ uid: 'html_rte_field', data_type: 'text', field_metadata: { rich_text_type: true } }
432+
];
433+
const sourceStackEntry = {
434+
html_rte_field: '<span data-uid="entry.10"></span><span data-uid="entry.1"></span>'
435+
};
436+
const entry = { html_rte_field: '<p></p>' };
437+
438+
const result = restoreJsonRteEntryRefs(entry, sourceStackEntry, ctSchema, {
439+
uidMapper: { 'entry.1': 'bltShort', 'entry.10': 'bltLong' },
440+
mappedAssetUids: {},
441+
mappedAssetUrls: {}
442+
});
443+
444+
expect(result.html_rte_field).to.equal('<span data-uid="bltLong"></span><span data-uid="bltShort"></span>');
445+
});
446+
447+
it('should remap regex-special-character entry UIDs in a multiple HTML RTE field', () => {
448+
const sourceUid = 'source.entry.1';
449+
const ctSchema = [
450+
{ uid: 'html_rte_field', data_type: 'text', field_metadata: { rich_text_type: true }, multiple: true }
451+
];
452+
// array-branch matching is exact-element (Array.indexOf), not substring, so the mapped
453+
// UID must be a whole array element here to be found
454+
const sourceStackEntry = {
455+
html_rte_field: [sourceUid, 'unrelated value']
456+
};
457+
const entry = { html_rte_field: ['placeholder'] };
458+
459+
const result = restoreJsonRteEntryRefs(entry, sourceStackEntry, ctSchema, {
460+
uidMapper: { [sourceUid]: 'bltTargetEntry1' },
461+
mappedAssetUids: {},
462+
mappedAssetUrls: {}
463+
});
464+
465+
expect(result.html_rte_field[0]).to.equal('bltTargetEntry1');
466+
expect(result.html_rte_field[1]).to.equal('unrelated value');
467+
});
468+
469+
it('should leave a UID embedded inside a larger string untouched in a multiple HTML RTE field', () => {
470+
const sourceUid = 'source.entry.1';
471+
const ctSchema = [
472+
{ uid: 'html_rte_field', data_type: 'text', field_metadata: { rich_text_type: true }, multiple: true }
473+
];
474+
const sourceStackEntry = {
475+
html_rte_field: [`<span data-uid="${sourceUid}"></span>`]
476+
};
477+
const entry = { html_rte_field: ['placeholder'] };
478+
479+
const result = restoreJsonRteEntryRefs(entry, sourceStackEntry, ctSchema, {
480+
uidMapper: { [sourceUid]: 'bltTargetEntry1' },
481+
mappedAssetUids: {},
482+
mappedAssetUrls: {}
483+
});
484+
485+
// known limitation, not touched by this fix: the multiple branch matches with
486+
// Array.prototype.indexOf (exact element equality), so a UID embedded inside a larger
487+
// string element is never found and the field passes through unchanged
488+
expect(result.html_rte_field[0]).to.equal(sourceStackEntry.html_rte_field[0]);
489+
});
490+
387491
it('should restore entry references in JSON RTE', () => {
388492
const entry = JSON.parse(JSON.stringify(mockEntries.entryWithJsonRteReference));
389493
const sourceStackEntry = mockEntries.sourceStackEntryWithJsonRte;

0 commit comments

Comments
 (0)