Skip to content

Commit 9994787

Browse files
authored
fix(commons): prevent deepMerge from mutating source objects (#5202)
1 parent 6b1a811 commit 9994787

5 files changed

Lines changed: 298 additions & 23 deletions

File tree

package-lock.json

Lines changed: 42 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/commons/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
"@aws/lambda-invoke-store": "0.2.4"
4545
},
4646
"devDependencies": {
47-
"@aws-lambda-powertools/testing-utils": "file:../testing"
47+
"@aws-lambda-powertools/testing-utils": "file:../testing",
48+
"fast-check": "^4.7.0"
4849
},
4950
"main": "./lib/cjs/index.js",
5051
"types": "./lib/cjs/index.d.ts",

packages/commons/src/deepMerge.ts

Lines changed: 79 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,44 +14,100 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
1414
};
1515

1616
/**
17-
* Merge source array items into target array by index.
17+
* Clone a source item for safe assignment into a target array.
18+
* Plain objects are cloned via mergeRecursive, arrays via mergeArrayItemsByIndex,
19+
* and primitives (including undefined) are returned as-is.
20+
* Returns `{ skip: true }` only when a circular array reference is detected.
1821
*
19-
* When both source and target items at the same index are plain objects,
20-
* they are merged recursively. Otherwise, the source item replaces the target.
22+
* @internal
23+
*/
24+
const cloneItem = (
25+
srcItem: unknown,
26+
ancestors: object[]
27+
): { skip: true } | { skip: false; value: unknown } => {
28+
if (isPlainObject(srcItem)) {
29+
const cloned: Record<string, unknown> = {};
30+
ancestors.push(srcItem);
31+
mergeRecursive(cloned, srcItem, ancestors);
32+
ancestors.pop();
33+
34+
return { skip: false, value: cloned };
35+
}
36+
37+
if (Array.isArray(srcItem)) {
38+
if (ancestors.includes(srcItem)) return { skip: true };
39+
const cloned: unknown[] = [];
40+
ancestors.push(srcItem);
41+
mergeArrayItemsByIndex(cloned, srcItem, ancestors);
42+
ancestors.pop();
43+
44+
return { skip: false, value: cloned };
45+
}
46+
47+
return { skip: false, value: srcItem };
48+
};
49+
50+
/**
51+
* Merge a single source item into a target array at the given index.
2152
*
2253
* @internal
2354
*/
24-
const mergeArrayItemsByIndex = (
55+
const mergeArrayItem = (
2556
targetArray: unknown[],
26-
sourceArray: unknown[],
57+
index: number,
58+
srcItem: unknown,
2759
ancestors: object[]
2860
): void => {
29-
for (let i = 0; i < sourceArray.length; i++) {
30-
const srcItem = sourceArray[i];
31-
const tgtItem = targetArray[i];
61+
const tgtItem = targetArray[index];
62+
const isSrcPlainObject = isPlainObject(srcItem);
3263

33-
const isSrcPlainObject = isPlainObject(srcItem);
64+
// Skip circular plain object references
65+
if (isSrcPlainObject && ancestors.includes(srcItem)) return;
3466

35-
// Skip objects in the current ancestor chain to prevent circular references
36-
if (isSrcPlainObject && ancestors.includes(srcItem)) {
37-
continue;
38-
}
67+
// Merge two plain objects recursively
68+
if (isSrcPlainObject && isPlainObject(tgtItem)) {
69+
ancestors.push(srcItem);
70+
mergeRecursive(tgtItem, srcItem, ancestors);
71+
ancestors.pop();
72+
73+
return;
74+
}
3975

40-
// Merge nested plain objects recursively
41-
if (isSrcPlainObject && isPlainObject(tgtItem)) {
76+
// Merge two arrays by index
77+
if (Array.isArray(srcItem) && Array.isArray(tgtItem)) {
78+
if (!ancestors.includes(srcItem)) {
4279
ancestors.push(srcItem);
43-
mergeRecursive(tgtItem, srcItem, ancestors);
80+
mergeArrayItemsByIndex(tgtItem, srcItem, ancestors);
4481
ancestors.pop();
45-
continue;
4682
}
4783

48-
// Otherwise, replace the target item with source item
49-
if (srcItem !== undefined || tgtItem === undefined) {
50-
targetArray[i] = srcItem;
84+
return;
85+
}
86+
87+
// Replace with a cloned copy of the source item
88+
if (srcItem !== undefined || tgtItem === undefined) {
89+
const result = cloneItem(srcItem, ancestors);
90+
if (!result.skip) {
91+
targetArray[index] = result.value;
5192
}
5293
}
5394
};
5495

96+
/**
97+
* Merge source array items into target array by index.
98+
*
99+
* @internal
100+
*/
101+
const mergeArrayItemsByIndex = (
102+
targetArray: unknown[],
103+
sourceArray: unknown[],
104+
ancestors: object[]
105+
): void => {
106+
for (let i = 0; i < sourceArray.length; i++) {
107+
mergeArrayItem(targetArray, i, sourceArray[i], ancestors);
108+
}
109+
};
110+
55111
/**
56112
* Handle merging when source value is an array.
57113
*
@@ -65,7 +121,9 @@ const handleArrayMerge = (
65121
ancestors: object[]
66122
): void => {
67123
if (!Array.isArray(targetValue)) {
68-
target[key] = [...sourceArray];
124+
const freshArray: unknown[] = [];
125+
mergeArrayItemsByIndex(freshArray, sourceArray, ancestors);
126+
target[key] = freshArray;
69127
return;
70128
}
71129

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import fc from 'fast-check';
2+
import { describe, expect, it } from 'vitest';
3+
import { deepMerge } from '../../src/deepMerge.js';
4+
5+
/**
6+
* Arbitrary that generates JSON-like plain objects (no class instances,
7+
* no circular refs) suitable for deepMerge property tests.
8+
*/
9+
const jsonObject = (): fc.Arbitrary<Record<string, unknown>> =>
10+
fc.dictionary(
11+
fc.string({ minLength: 1, maxLength: 5 }),
12+
fc.letrec((tie) => ({
13+
value: fc.oneof(
14+
{ depthSize: 'small' },
15+
fc.string(),
16+
fc.integer(),
17+
fc.boolean(),
18+
fc.constant(null),
19+
fc.array(tie('value'), { maxLength: 3, depthSize: 'small' }),
20+
fc.dictionary(fc.string({ minLength: 1, maxLength: 5 }), tie('value'), {
21+
maxKeys: 3,
22+
})
23+
),
24+
})).value,
25+
{ maxKeys: 5 }
26+
);
27+
28+
describe('deepMerge property tests', () => {
29+
it('never mutates source objects', () => {
30+
fc.assert(
31+
fc.property(
32+
fc.array(jsonObject(), { minLength: 1, maxLength: 4 }),
33+
(sources) => {
34+
const snapshots = sources.map((s) => structuredClone(s));
35+
36+
deepMerge({}, ...sources);
37+
38+
for (let i = 0; i < sources.length; i++) {
39+
expect(sources[i]).toEqual(snapshots[i]);
40+
}
41+
}
42+
),
43+
{ numRuns: 200 }
44+
);
45+
});
46+
47+
it('returns the target reference', () => {
48+
fc.assert(
49+
fc.property(jsonObject(), jsonObject(), (target, source) => {
50+
const result = deepMerge(target, source);
51+
expect(result).toBe(target);
52+
}),
53+
{ numRuns: 200 }
54+
);
55+
});
56+
57+
it('result contains all top-level keys from all sources', () => {
58+
fc.assert(
59+
fc.property(
60+
fc.array(jsonObject(), { minLength: 1, maxLength: 4 }),
61+
(sources) => {
62+
const result = deepMerge({}, ...sources);
63+
const resultKeys = new Set(Object.keys(result));
64+
65+
for (const source of sources) {
66+
for (const key of Object.keys(source)) {
67+
expect(resultKeys.has(key)).toBe(true);
68+
}
69+
}
70+
}
71+
),
72+
{ numRuns: 200 }
73+
);
74+
});
75+
76+
it('merging a source into empty object then merging same source again is idempotent', () => {
77+
fc.assert(
78+
fc.property(jsonObject(), (source) => {
79+
const first = deepMerge({}, source);
80+
const second = deepMerge(structuredClone(first), source);
81+
expect(second).toEqual(first);
82+
}),
83+
{ numRuns: 200 }
84+
);
85+
});
86+
});

packages/commons/tests/unit/deepMerge.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,26 @@ describe('Function: deepMerge', () => {
332332
expect(result).toHaveProperty('arr[1]', 2);
333333
});
334334

335+
it('skips circular arrays nested inside arrays', () => {
336+
// Prepare - outer array contains itself as an element
337+
const outer: unknown[] = [1, 2];
338+
outer.push(outer); // outer[2] = outer (circular)
339+
const target = {
340+
arr: [
341+
[10, 20],
342+
[30, 40],
343+
[50, 60],
344+
],
345+
};
346+
const source = { arr: outer };
347+
348+
// Act
349+
const result = deepMerge(target, source);
350+
351+
// Assess - index 0 and 1 are primitives replacing arrays, index 2 is circular so skipped
352+
expect(result).toEqual({ arr: [1, 2, [50, 60]] });
353+
});
354+
335355
it('skips array that references an ancestor array', () => {
336356
// Prepare
337357
const arr: unknown[] = [];
@@ -556,6 +576,75 @@ describe('Function: deepMerge', () => {
556576
});
557577
});
558578

579+
describe('Source immutability', () => {
580+
it('does not mutate sources when merging arrays of objects across multiple sources', () => {
581+
// Prepare
582+
const source1 = { a: [{ a: 1 }] };
583+
const source2 = { a: [{ b: 2 }] };
584+
585+
// Act
586+
const result = deepMerge({}, source1, source2);
587+
588+
// Assess
589+
expect(result).toEqual({ a: [{ a: 1, b: 2 }] });
590+
expect(source1).toEqual({ a: [{ a: 1 }] });
591+
expect(source2).toEqual({ a: [{ b: 2 }] });
592+
});
593+
594+
it('does not mutate sources when merging nested arrays across multiple sources', () => {
595+
// Prepare
596+
const src1 = { a: [[1, 2, 3]] };
597+
const src2 = { a: [[3, 4]] };
598+
599+
// Act
600+
const result = deepMerge({}, src1, src2);
601+
602+
// Assess
603+
expect(result).toEqual({ a: [[3, 4, 3]] });
604+
expect(src1).toEqual({ a: [[1, 2, 3]] });
605+
expect(src2).toEqual({ a: [[3, 4]] });
606+
});
607+
608+
it('does not mutate a single source with array of objects merged into empty target', () => {
609+
// Prepare
610+
const source = { a: [{ x: 1 }, { y: 2 }] };
611+
612+
// Act
613+
const result = deepMerge({}, source);
614+
result.a[0].x = 999;
615+
616+
// Assess
617+
expect(source).toEqual({ a: [{ x: 1 }, { y: 2 }] });
618+
});
619+
620+
it('does not mutate sources with plain objects (non-array path)', () => {
621+
// Prepare
622+
const source1 = { nested: { a: 1 } };
623+
const source2 = { nested: { b: 2 } };
624+
625+
// Act
626+
deepMerge({}, source1, source2);
627+
628+
// Assess
629+
expect(source1).toEqual({ nested: { a: 1 } });
630+
expect(source2).toEqual({ nested: { b: 2 } });
631+
});
632+
633+
it('does not mutate sources with mixed arrays and nested objects', () => {
634+
// Prepare
635+
const source1 = { data: [{ items: [{ id: 1 }] }] };
636+
const source2 = { data: [{ items: [{ name: 'a' }] }] };
637+
638+
// Act
639+
const result = deepMerge({}, source1, source2);
640+
641+
// Assess
642+
expect(result).toEqual({ data: [{ items: [{ id: 1, name: 'a' }] }] });
643+
expect(source1).toEqual({ data: [{ items: [{ id: 1 }] }] });
644+
expect(source2).toEqual({ data: [{ items: [{ name: 'a' }] }] });
645+
});
646+
});
647+
559648
describe('Edge cases', () => {
560649
it('handles Date objects (treats as value, not merged)', () => {
561650
// Prepare

0 commit comments

Comments
 (0)