diff --git a/src/shared/crash-error-description.test.ts b/src/shared/crash-error-description.test.ts index c5d9273aece..81d1ec9542e 100644 --- a/src/shared/crash-error-description.test.ts +++ b/src/shared/crash-error-description.test.ts @@ -2,6 +2,43 @@ import { describe, expect, it } from 'vitest' import { describeCrashError } from './crash-error-description' describe('describeCrashError', () => { + it('does not throw when a non-Error value cannot be converted to a string', () => { + const hostile = { + [Symbol.toPrimitive](): never { + throw new Error('conversion failed') + } + } + + expect(() => describeCrashError(hostile)).not.toThrow() + expect(describeCrashError(hostile)).toMatchObject({ + errorName: 'NonErrorThrown', + errorMessage: '[unprintable thrown value]' + }) + }) + + it('normalizes malformed Error fields before sanitizing them', () => { + const error = new Error('fallback') + Object.defineProperties(error, { + message: { value: Symbol('message') }, + name: { value: 42 }, + stack: { + get() { + return { + toString(): never { + throw new Error('conversion failed') + } + } + } + } + }) + + expect(describeCrashError(error)).toMatchObject({ + errorName: '42', + errorMessage: 'Symbol(message)' + }) + expect(describeCrashError(error)).not.toHaveProperty('errorStack') + }) + it('retains sanitized messages and the full sanitized stack alongside a fingerprint', () => { const error = new Error( [ diff --git a/src/shared/crash-error-description.ts b/src/shared/crash-error-description.ts index 3b0c86174b6..4b4ae147cc7 100644 --- a/src/shared/crash-error-description.ts +++ b/src/shared/crash-error-description.ts @@ -8,21 +8,52 @@ export function describeCrashError( error: unknown, componentStack?: string | null ): CrashErrorDescription { - const candidate = error instanceof Error ? error : null - const message = candidate?.message ?? String(error) + let candidate: Error | null = null + try { + candidate = error instanceof Error ? error : null + } catch { + // A revoked proxy can throw while checking its prototype. + } + + const message = safeString( + readErrorProperty(candidate, 'message') ?? error, + '[unprintable thrown value]' + ) + const rawName = readErrorProperty(candidate, 'name') + const name = rawName == null ? 'NonErrorThrown' : safeString(rawName, 'NonErrorThrown') + const rawStack = readErrorProperty(candidate, 'stack') + const stack = rawStack == null ? '' : safeString(rawStack, '') + return { - errorName: sanitizeCrashReportString(candidate?.name || 'NonErrorThrown', 80), + errorName: sanitizeCrashReportString(name || 'NonErrorThrown', 80), errorMessage: sanitizeCrashReportString(message), errorFingerprint: fingerprint(message), - ...(candidate?.stack - ? { errorStack: sanitizeCrashReportString(candidate.stack, MAX_STACK_CHARS) } - : {}), - ...(componentStack?.trim() + ...(stack ? { errorStack: sanitizeCrashReportString(stack, MAX_STACK_CHARS) } : {}), + ...(typeof componentStack === 'string' && componentStack.trim() ? { componentStack: sanitizeCrashReportString(componentStack.trim(), MAX_STACK_CHARS) } : {}) } } +function readErrorProperty(error: Error | null, key: 'message' | 'name' | 'stack'): unknown { + try { + return error?.[key] + } catch { + return undefined + } +} + +function safeString(value: unknown, fallback: string): string { + if (typeof value === 'string') { + return value + } + try { + return String(value) + } catch { + return fallback + } +} + function fingerprint(value: string): string { let hash = 0x811c9dc5 for (let index = 0; index < value.length; index += 1) {