diff --git a/.changeset/reject-cyclic-tags.md b/.changeset/reject-cyclic-tags.md new file mode 100644 index 00000000..5d7188bf --- /dev/null +++ b/.changeset/reject-cyclic-tags.md @@ -0,0 +1,5 @@ +--- +'@fingerprint/react-native': patch +--- + +Fixed `tags` validation to reject circular references with a clear `TypeError` instead of throwing an unhandled `RangeError: Maximum call stack size exceeded`. diff --git a/sdk/__tests__/tags.test.ts b/sdk/__tests__/tags.test.ts index 1f890468..95ab4215 100644 --- a/sdk/__tests__/tags.test.ts +++ b/sdk/__tests__/tags.test.ts @@ -16,4 +16,45 @@ describe('validateTags', () => { }) }).toThrow(new TypeError("tags['value']['numeric'] must be a finite number")) }) + + it('rejects a list that contains itself', () => { + const cyclic: unknown[] = ['a'] + cyclic.push(cyclic) + + expect(() => { + validateTags({ items: cyclic }) + }).toThrow(new TypeError("tags['items'][1] contains a circular reference")) + }) + + it('rejects a map that contains itself', () => { + const cyclic: Record = {} + cyclic.self = cyclic + + expect(() => { + validateTags(cyclic) + }).toThrow(new TypeError("tags['self'] contains a circular reference")) + }) + + it('rejects a cycle that closes further down', () => { + const outer: Record = {} + outer.items = [{ back: outer }] + + expect(() => { + validateTags(outer) + }).toThrow(new TypeError("tags['items'][0]['back'] contains a circular reference")) + }) + + it('accepts the same object referenced twice when it is not a cycle', () => { + const shared = { nested: true } + + expect(() => { + validateTags({ left: shared, right: shared }) + }).not.toThrow() + + expect(() => { + validateTags({ + items: [shared, shared], + }) + }).not.toThrow() + }) }) diff --git a/sdk/src/tags.ts b/sdk/src/tags.ts index 049d599f..1b3e62a5 100644 --- a/sdk/src/tags.ts +++ b/sdk/src/tags.ts @@ -1,5 +1,5 @@ /** - * Rejects NaN / Infinity in `tags`. + * Rejects NaN / Infinity and circular references in `tags`. * * `TagsPrimitive` is `number`, so `{ score: Number('oops') }` type-checks. * JSON has no literal for those values. Each platform rewrites them @@ -7,6 +7,9 @@ * `null` on Android) and identification still succeeds. Throw instead of * storing the wrong tag. * + * A circular reference makes this walk recurse forever. Reject it with a + * clean error instead of letting it blow the call stack. + * * `Date`, `Map`, and class instances are already a type error on `TagsValue`. * This walk does not reject them. The RN bridge and JS agent do not agree on * those values (a `Date` is an ISO string on web and `{}` on native), but we @@ -20,10 +23,10 @@ export function validateTags(tags?: unknown): void { if (tags === null || tags === undefined) { return } - walk(tags, 'tags') + walk(tags, 'tags', []) } -function walk(value: unknown, path: string): void { +function walk(value: unknown, path: string, ancestors: unknown[]): void { if (value === null || value === undefined) { return } @@ -39,14 +42,20 @@ function walk(value: unknown, path: string): void { return } + if (ancestors.includes(value)) { + throw new TypeError(`${path} contains a circular reference`) + } + ancestors.push(value) + if (Array.isArray(value)) { for (const [index, entry] of value.entries()) { - walk(entry, `${path}[${String(index)}]`) + walk(entry, `${path}[${String(index)}]`, ancestors) + } + } else { + for (const [key, entry] of Object.entries(value)) { + walk(entry, `${path}['${key}']`, ancestors) } - return } - for (const [key, entry] of Object.entries(value)) { - walk(entry, `${path}['${key}']`) - } + ancestors.pop() }