fix(crash-reporting): stop destroying user crash notes (#15252)

This commit is contained in:
Neil
2026-08-24 21:03:34 -07:00
committed by GitHub
parent 5869e6d39e
commit 788575e300
5 changed files with 395 additions and 114 deletions
+2 -1
View File
@@ -138,7 +138,7 @@ describe('registerCrashReportingHandlers', () => {
_resetRendererErrorReportDedupeForTests()
})
it('copies the latest pending diagnostic text to the clipboard', async () => {
it('copies the requested captured report to the clipboard', async () => {
const latest = report()
registerCrashReportingHandlers({
getById: vi.fn(async () => latest),
@@ -151,6 +151,7 @@ describe('registerCrashReportingHandlers', () => {
} as never)
const result = await handlers.get('crashReports:copyLatestDiagnostics')?.(null, {
reportId: latest.id,
notes: 'extra /Users/alice/project'
})
@@ -16,6 +16,7 @@ import { useMountedRef } from '@/hooks/useMountedRef'
import {
formatCrashReportText,
isReactErrorBoundaryReport,
MAX_USER_NOTES_LENGTH,
type CrashReportDiagnosticBundle,
type CrashReportRecord
} from '../../../../shared/crash-reporting'
@@ -277,13 +278,23 @@ export function CrashReportDialogSurface({
)}
</div>
)}
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
rows={4}
placeholder={getNotesPlaceholder(report)}
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div className="space-y-1">
<textarea
value={notes}
onChange={(event) => setNotes(event.target.value)}
rows={4}
// Keep the UI and formatter on the same input budget.
maxLength={MAX_USER_NOTES_LENGTH}
placeholder={getNotesPlaceholder(report)}
className="min-h-24 w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
<div
aria-hidden="true"
className="text-right text-[11px] tabular-nums text-muted-foreground"
>
{notes.length.toLocaleString()} / {MAX_USER_NOTES_LENGTH.toLocaleString()}
</div>
</div>
<div className="flex items-start gap-2 rounded-md border border-border/70 bg-muted/20 p-3">
<Checkbox
id="crash-report-attach-diagnostics"
+109
View File
@@ -0,0 +1,109 @@
import type {
CrashReportBreadcrumb,
CrashReportBreadcrumbInput,
CrashReportDetailValue
} from './crash-reporting'
const MAX_STRING_DETAIL_LENGTH = 240
const MAX_STACK_DETAIL_LENGTH = 4_000
const MAX_BREADCRUMB_NAME_LENGTH = 80
const MAX_BREADCRUMBS = 30
const SECRET_PATTERNS = [
/\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
/\bglpat-[A-Za-z0-9_-]{20,}\b/g,
/\bsk-[A-Za-z0-9_-]{20,}\b/g,
/\bxox[baprs]-[A-Za-z0-9-]{10,}/g,
/\bAKIA[0-9A-Z]{16}\b/g,
/\bBearer\s+[A-Za-z0-9._~+/-]{20,}/gi,
/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g
]
const CREDENTIAL_URL_PATTERN = /\b[A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@(?=[^/\s]+)/g
const SECRET_ASSIGNMENT_PATTERN =
/\b(token|access[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|secret|password|account[_-]?key)\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^&\s,;]+)/gi
// Quoted paths retain spaces; unquoted paths stop at whitespace to preserve prose.
const PATH_PATTERNS = [
/(["'`])\/[A-Za-z0-9._-]+\/(?:(?!\1)[^<>\n\r])+\1/g,
/(["'`])[A-Za-z]:\\(?:(?!\1)[^<>\n\r])+\1/gi,
/(["'`])\\\\[^\\\s"'`<>\n\r)]+\\(?:(?!\1)[^<>\n\r])+\1/gi,
/(?<![A-Za-z0-9./])\/[A-Za-z0-9._-]+\/(?:\\ |[^\s"'`<>)]*)/g,
/(?<![A-Za-z0-9])[A-Za-z]:\\(?:\\ |[^\s"'`<>\n\r)]*)/gi,
/\\\\[^\\\s"'`<>\n\r)]+\\(?:\\ |[^\s"'`<>\n\r)]*)/gi,
/%(?:USERPROFILE|APPDATA|LOCALAPPDATA|HOMEDRIVE|HOMEPATH)%[^\s"'`<>)]*/gi
]
export function sanitizeCrashReportString(
value: string,
maxLength = MAX_STRING_DETAIL_LENGTH
): string {
let sanitized = value
for (const pattern of PATH_PATTERNS) {
sanitized = sanitized.replace(pattern, '[redacted-path]')
}
sanitized = sanitized.replace(CREDENTIAL_URL_PATTERN, '[redacted-credential]@')
sanitized = sanitized.replace(SECRET_ASSIGNMENT_PATTERN, (_match, key: string) => {
return `${key}=[redacted]`
})
for (const pattern of SECRET_PATTERNS) {
sanitized = sanitized.replace(pattern, '[redacted-secret]')
}
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
}
export function sanitizeCrashReportDetails(
details: Record<string, unknown>
): Record<string, CrashReportDetailValue> {
const sanitized: Record<string, CrashReportDetailValue> = {}
for (const [key, value] of Object.entries(details)) {
if (typeof value === 'string') {
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
if (/(?:^|_)path$/i.test(normalizedKey)) {
sanitized[key] = '[redacted-path]'
} else {
const maxLength =
/(?:^|_)(?:stack|component_stack|error_stack|minidump_check_message)$/i.test(
normalizedKey
)
? MAX_STACK_DETAIL_LENGTH
: MAX_STRING_DETAIL_LENGTH
sanitized[key] = sanitizeCrashReportString(value, maxLength)
}
} else if (typeof value === 'number' && Number.isFinite(value)) {
sanitized[key] = value
} else if (typeof value === 'boolean' || value === null) {
sanitized[key] = value
}
}
return sanitized
}
export function sanitizeCrashReportBreadcrumbs(
breadcrumbs: CrashReportBreadcrumbInput[] | undefined
): CrashReportBreadcrumb[] | undefined {
if (!breadcrumbs || breadcrumbs.length === 0) {
return undefined
}
const sanitized = breadcrumbs
.slice(-MAX_BREADCRUMBS)
.map((breadcrumb): CrashReportBreadcrumb | null => {
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
return null
}
const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {}
const origin = breadcrumb.origin
? sanitizeCrashReportString(breadcrumb.origin).slice(0, 80)
: ''
return {
createdAt: sanitizeCrashReportString(breadcrumb.createdAt),
name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH),
...(Object.keys(data).length > 0 ? { data } : {}),
...(origin ? { origin } : {})
}
})
.filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null)
return sanitized.length > 0 ? sanitized : undefined
}
+224 -6
View File
@@ -3,19 +3,57 @@ import {
formatCrashReportText,
formatUncapturedCrashReportText,
isCrashReportReason,
MAX_USER_NOTES_LENGTH,
sanitizeCrashReportBreadcrumbs,
sanitizeCrashReportDetails,
sanitizeCrashReportString,
type CrashReportRecord
} from './crash-reporting'
function notesReport(overrides: Partial<CrashReportRecord> = {}): CrashReportRecord {
return {
id: 'crash-notes',
createdAt: '2026-08-16T01:00:00.000Z',
status: 'pending',
source: 'renderer',
processType: 'renderer',
reason: 'crashed',
exitCode: 5,
appVersion: '1.4.184',
platform: 'win32',
osRelease: '10.0.26200',
arch: 'x64',
electronVersion: '41.0.0',
chromeVersion: '141.0.0',
details: {},
breadcrumbs: [],
...overrides
}
}
/** Notes are emitted indented inside the fence; mirror that when asserting. */
function indentNote(note: string): string {
return note
.split('\n')
.map((line) => ` ${line}`)
.join('\n')
}
describe('crash-reporting shared helpers', () => {
it('redacts paths and common secret-shaped strings', () => {
const text =
'file /Users/alice/My Project/.env /tmp/build log C:\\Users\\bob\\My Project token=abc123 ghp_abcdefghijklmnopqrstuvwxyz'
'file "/Users/alice/My Project/.env" /tmp/build log "C:\\Users\\bob\\My Project" token=abc123 ghp_abcdefghijklmnopqrstuvwxyz'
expect(sanitizeCrashReportString(text)).toBe(
'file [redacted-path] [redacted-path] [redacted-path] token=[redacted] [redacted-secret]'
'file [redacted-path] [redacted-path] log [redacted-path] token=[redacted] [redacted-secret]'
)
})
it('redacts credential URLs and secret assignments without hiding their labels', () => {
const value = 'https://alice:hunter2@example.com client_secret: "secret with spaces"'
expect(sanitizeCrashReportString(value)).toBe(
'https://[redacted-credential]@example.com client_secret=[redacted]'
)
})
@@ -35,6 +73,7 @@ describe('crash-reporting shared helpers', () => {
crashed: true,
missing: null,
error_stack: longStack,
minidumpPath: '/Users/alice/Library/Application Support/Orca/reports/abc.dmp',
nested: { nope: true },
infinite: Number.POSITIVE_INFINITY
})
@@ -43,7 +82,8 @@ describe('crash-reporting shared helpers', () => {
code: 9,
crashed: true,
missing: null,
error_stack: expect.stringContaining('[redacted-path]')
error_stack: expect.stringContaining('[redacted-path]'),
minidumpPath: '[redacted-path]'
})
expect(
String(sanitizeCrashReportDetails({ error_stack: longStack }).error_stack).length
@@ -75,6 +115,7 @@ describe('crash-reporting shared helpers', () => {
Array.from({ length: 32 }, (_, index) => ({
createdAt: `2026-05-16T01:${String(index).padStart(2, '0')}:00.000Z`,
name: `event_${index}`,
origin: 'renderer:42',
data: {
path: '/Users/alice/project',
ok: true,
@@ -85,9 +126,12 @@ describe('crash-reporting shared helpers', () => {
expect(breadcrumbs).toHaveLength(30)
expect(breadcrumbs?.[0].name).toBe('event_2')
expect(breadcrumbs?.[0].data).toEqual({
path: '[redacted-path]',
ok: true
expect(breadcrumbs?.[0]).toMatchObject({
origin: 'renderer:42',
data: {
path: '[redacted-path]',
ok: true
}
})
})
@@ -281,4 +325,178 @@ describe('crash-reporting shared helpers', () => {
expect(text).toContain('Status: not uploaded')
expect(text).toContain('[redacted-path]')
})
it('keeps a user note longer than the 240-char detail cap intact', () => {
// A real 1.4.184 note was cut mid-word by the telemetry detail budget.
const note =
`My phone is connected. ${'The Claude terminal never came back. '.repeat(20)}`.trim()
const text = formatCrashReportText(notesReport(), note)
expect(note.length).toBeGreaterThan(240)
expect(text).toContain(`--- begin user notes ---\n${indentNote(note)}\n--- end user notes ---`)
expect(text).not.toContain('...')
})
it('still redacts paths and secrets far past the old 240-char cap', () => {
const note = [
'a'.repeat(1_000),
'it broke at /Users/alice/secret-project',
'my token was ghp_abcdefghijklmnopqrstuvwxyz',
'b'.repeat(1_000)
].join('\n')
const text = formatCrashReportText(notesReport(), note)
expect(text).toContain('it broke at [redacted-path]')
expect(text).toContain('my token was [redacted-secret]')
expect(text).not.toContain('alice')
expect(text).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz')
})
it('bounds an oversized user note to the advertised limit', () => {
const text = formatCrashReportText(notesReport(), 'z'.repeat(40_000))
const expected = `${'z'.repeat(MAX_USER_NOTES_LENGTH - 3)}...`
expect(text).toContain(expected)
expect(text).not.toContain('z'.repeat(MAX_USER_NOTES_LENGTH - 2))
})
it('keeps user notes when the report is truncated to the endpoint cap', () => {
// Tail truncation must remove reproducible machine data before user notes.
const text = formatCrashReportText(
notesReport({
details: Object.fromEntries(
Array.from({ length: 400 }, (_, index) => [`detail_${index}`, 'x'.repeat(240)])
)
}),
'the sidebar went blank'
)
expect(text.length).toBeLessThanOrEqual(64_000)
expect(text).toContain('[Crash report truncated to fit feedback endpoint limits.]')
expect(text).toContain('--- begin user notes ---\n the sidebar went blank')
})
it('redacts path tokens without deleting surrounding prose', () => {
const note = [
'On 8/16/2026 the app froze right after I opened a worktree.',
'Steps: open View/Layout then Window/Zoom and it crashes on run 3/4.',
'The log is at /opt/orca/logs/app.log and the repo is /Users/alice/x but this survives.'
].join(' ')
const text = formatCrashReportText(notesReport(), note)
expect(text).toContain('On 8/16/2026 the app froze')
expect(text).toContain('open View/Layout then Window/Zoom and it crashes on run 3/4.')
expect(text).toContain(
'The log is at [redacted-path] and the repo is [redacted-path] but this survives.'
)
expect(text).not.toContain('/opt/orca/logs/app.log')
expect(text).not.toContain('alice')
})
it.each([
['POSIX', '/home/alice/orca/app.log then recovered.'],
['Windows', 'C:\\Users\\alice\\Orca\\app.log then recovered.'],
['UNC', '\\\\server\\share\\Orca\\app.log then recovered.']
])('stops unquoted %s paths at prose boundaries', (_platform, value) => {
expect(sanitizeCrashReportString(value)).toBe('[redacted-path] then recovered.')
})
it('redacts the secret shapes a full-page notes box can now hold', () => {
const note = [
'pat github_pat_11AAAAAAA0abcdefghijklmnopqrstuvwxyz012345',
// Assembling the fixture avoids GitHub push-protection false positives.
`slack ${['xoxb', '0'.repeat(11), 'fixture', 'not-a-real-token'].join('-')}`,
`gitlab ${['glpat', 'a'.repeat(24)].join('-')}`,
'aws AKIAIOSFODNN7EXAMPLE',
'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.body.sig',
'authorization: bearer eyJhbGciOiJIUzI1NiJ9.lowercase.signature',
'client_secret: "secret with spaces"',
'-----BEGIN OPENSSH PRIVATE KEY-----',
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAAB',
'-----END OPENSSH PRIVATE KEY-----',
'log at %USERPROFILE%\\Documents\\payroll.xlsx'
].join('\n')
const text = formatCrashReportText(notesReport(), note)
expect(text).not.toContain('github_pat_11AAAAAAA0')
expect(text).not.toContain('not-a-real-token')
expect(text).not.toContain('glpat-')
expect(text).not.toContain('AKIAIOSFODNN7EXAMPLE')
expect(text).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')
expect(text).not.toContain('eyJhbGciOiJIUzI1NiJ9.lowercase.signature')
expect(text).not.toContain('secret with spaces')
expect(text).toContain('client_secret=[redacted]')
expect(text).not.toContain('b3BlbnNzaC1rZXktdjEA')
expect(text).not.toContain('payroll.xlsx')
})
it('redacts an incomplete private-key paste', () => {
const text = formatCrashReportText(
notesReport(),
'-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA'
)
expect(text).not.toContain('b3BlbnNzaC1rZXktdjEAAAAA')
expect(text).toContain('[redacted-secret]')
})
it('bounds sanitizer work on a padded paste instead of freezing the dialog', () => {
// The raw-input clamp prevents path regexes from scanning an unbounded paste.
const note = `/Users/a${' '.repeat(200_000)}end`
const startedAt = Date.now()
const text = formatCrashReportText(notesReport(), note)
expect(Date.now() - startedAt).toBeLessThan(1_000)
expect(text.length).toBeLessThan(64_000)
})
it('clamps raw notes before trimming', () => {
const text = formatCrashReportText(
notesReport(),
`${' '.repeat(20_000)}content beyond the raw-input limit`
)
expect(text).not.toContain('content beyond the raw-input limit')
expect(text).not.toContain('User notes:')
})
it('places Help-menu notes before machine-generated fields', () => {
const text = formatUncapturedCrashReportText(
{
createdAt: '2026-05-16T01:00:00.000Z',
appVersion: '1.0.0',
platform: 'darwin',
osRelease: '25.0.0',
arch: 'arm64',
electronVersion: '41.0.0',
chromeVersion: '141.0.0'
},
'the terminal font looks wrong'
)
expect(text.startsWith('[Crash Report]')).toBe(true)
expect(text).toContain('- captured_crash_report: false')
expect(text).toContain('--- begin user notes ---\n the terminal font looks wrong')
expect(text.indexOf('--- begin user notes ---')).toBeLessThan(text.indexOf('Details:'))
})
})
describe('user note section fencing', () => {
it('stops a note from forging a machine-generated section', () => {
const text = formatCrashReportText(
notesReport({ details: { captured_crash_report: true } }),
'here is what I saw\n\nDetails:\n- captured_crash_report: false'
)
// Only the generated Details heading may remain line-parser-visible.
expect(text.match(/^Details:$/gm)).toHaveLength(1)
expect(text).not.toMatch(/^- captured_crash_report: false$/m)
expect(text).toContain(' Details:')
expect(text).toContain(' - captured_crash_report: false')
expect(text).toMatch(/^- captured_crash_report: true$/m)
})
})
+42 -100
View File
@@ -6,6 +6,13 @@ import { appendMinidumpSignatureLines } from './crash-report-signature-lines'
import { formatCrashReportExitCode } from './crash-report-exit-code'
import { appendBoundaryAttributionLines } from './crash-report-attribution-lines'
import type { CrashReportAttribution } from './react-update-depth-attribution'
import { sanitizeCrashReportString } from './crash-report-redaction'
export {
sanitizeCrashReportBreadcrumbs,
sanitizeCrashReportDetails,
sanitizeCrashReportString
} from './crash-report-redaction'
export type { CrashReportDiagnosticBundle } from './crash-reporting-diagnostic-bundle'
@@ -124,27 +131,14 @@ export type CrashReportCopyDiagnosticsArgs = {
submissionFailure?: CrashReportCopySubmissionFailure
}
const MAX_STRING_DETAIL_LENGTH = 240
const MAX_STACK_DETAIL_LENGTH = 4_000
const MAX_BREADCRUMB_NAME_LENGTH = 80
const MAX_BREADCRUMBS = 30
// User notes need a prose budget, separate from 240-character telemetry values.
export const MAX_USER_NOTES_LENGTH = 8_000
// Bound redaction work while allowing redacted input to contract into the output budget.
const MAX_USER_NOTES_SANITIZE_LENGTH = MAX_USER_NOTES_LENGTH * 2
const USER_NOTES_TRUNCATION_SUFFIX = '...'
const MAX_FORMATTED_REPORT_LENGTH = 64_000
const FORMATTED_REPORT_TRUNCATION_SUFFIX =
'\n\n[Crash report truncated to fit feedback endpoint limits.]'
const SECRET_PATTERNS = [
/\b(gh[pousr]_[A-Za-z0-9_]{20,})\b/g,
/\b(sk-[A-Za-z0-9_-]{20,})\b/g,
/\b([A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@)(?=[^/\s]+)/g,
/\b(token|api[_-]?key|secret|password)=([^&\s]+)/gi
]
const PATH_PATTERNS = [
/\/(?:Users|home)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
/\/(?:Applications|Library|System|Volumes|etc|media|mnt|opt|private|root|srv|tmp|usr|var)\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
/\/[A-Za-z0-9._ -]+\/(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
/[A-Za-z]:\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi,
/\\\\[^\\\s"'`<>\n\r)]+\\(?:(?!\s+(?:\/|[A-Za-z]:\\|\\\\|gh[pousr]_|sk-|(?:token|api[_-]?key|secret|password)=))[^"'`<>\n\r)])+/gi
]
export function isCrashReportReason(reason: string): boolean {
return [
'abnormal-exit',
@@ -165,74 +159,34 @@ export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean {
)
}
export function sanitizeCrashReportString(
value: string,
maxLength = MAX_STRING_DETAIL_LENGTH
): string {
let sanitized = value
for (const pattern of PATH_PATTERNS) {
sanitized = sanitized.replace(pattern, '[redacted-path]')
}
for (const pattern of SECRET_PATTERNS) {
sanitized = sanitized.replace(pattern, (match, key?: string) => {
if (key && /^(token|api[_-]?key|secret|password)$/i.test(key)) {
return `${key}=[redacted]`
}
return match.includes('@') ? '[redacted-credential]@' : '[redacted-secret]'
})
}
return sanitized.length > maxLength ? `${sanitized.slice(0, maxLength)}...` : sanitized
}
// Notes lead so endpoint truncation removes reproducible machine data first.
const USER_NOTES_BEGIN = '--- begin user notes ---'
const USER_NOTES_END = '--- end user notes ---'
function maxDetailStringLengthForKey(key: string): number {
const normalizedKey = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
return /(?:^|_)(?:stack|component_stack|error_stack|minidump_check_message)$/i.test(normalizedKey)
? MAX_STACK_DETAIL_LENGTH
: MAX_STRING_DETAIL_LENGTH
}
export function sanitizeCrashReportDetails(
details: Record<string, unknown>
): Record<string, CrashReportDetailValue> {
const sanitized: Record<string, CrashReportDetailValue> = {}
for (const [key, value] of Object.entries(details)) {
if (typeof value === 'string') {
sanitized[key] = sanitizeCrashReportString(value, maxDetailStringLengthForKey(key))
} else if (typeof value === 'number' && Number.isFinite(value)) {
sanitized[key] = value
} else if (typeof value === 'boolean' || value === null) {
sanitized[key] = value
}
function appendUserNotesLines(lines: string[], notes: string | undefined): void {
if (!notes) {
return
}
return sanitized
}
export function sanitizeCrashReportBreadcrumbs(
breadcrumbs: CrashReportBreadcrumbInput[] | undefined
): CrashReportBreadcrumb[] | undefined {
if (!breadcrumbs || breadcrumbs.length === 0) {
return undefined
const inputWasClamped = notes.length > MAX_USER_NOTES_SANITIZE_LENGTH
const boundedNotes = notes.slice(0, MAX_USER_NOTES_SANITIZE_LENGTH).trim()
if (!boundedNotes) {
return
}
const sanitized = breadcrumbs
.slice(-MAX_BREADCRUMBS)
.map((breadcrumb): CrashReportBreadcrumb | null => {
if (!breadcrumb.name.trim() || !breadcrumb.createdAt.trim()) {
return null
}
const data = breadcrumb.data ? sanitizeCrashReportDetails(breadcrumb.data) : {}
const origin = breadcrumb.origin
? sanitizeCrashReportString(breadcrumb.origin).slice(0, 80)
: ''
return {
createdAt: sanitizeCrashReportString(breadcrumb.createdAt),
name: sanitizeCrashReportString(breadcrumb.name).slice(0, MAX_BREADCRUMB_NAME_LENGTH),
...(Object.keys(data).length > 0 ? { data } : {}),
...(origin ? { origin } : {})
}
})
.filter((breadcrumb): breadcrumb is CrashReportBreadcrumb => breadcrumb !== null)
return sanitized.length > 0 ? sanitized : undefined
const sanitized = sanitizeCrashReportString(boundedNotes, MAX_USER_NOTES_SANITIZE_LENGTH)
const wasTruncated = inputWasClamped || sanitized.length > MAX_USER_NOTES_LENGTH
const formattedNotes = wasTruncated
? `${sanitized
.slice(0, MAX_USER_NOTES_LENGTH - USER_NOTES_TRUNCATION_SUFFIX.length)
.trimEnd()}${USER_NOTES_TRUNCATION_SUFFIX}`
: sanitized
// Indentation prevents user text from impersonating line-oriented machine sections.
lines.push(
'',
'User notes:',
USER_NOTES_BEGIN,
...formattedNotes.split('\n').map((line) => ` ${line}`),
USER_NOTES_END
)
}
export function formatCrashReportText(
@@ -256,6 +210,7 @@ export function formatCrashReportText(
`Chrome: ${report.chromeVersion}`
]
appendUserNotesLines(lines, notes)
appendMinidumpSignatureLines(lines, report.details)
appendBoundaryAttributionLines(lines, report.details)
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
@@ -280,11 +235,6 @@ export function formatCrashReportText(
}
}
const trimmedNotes = notes?.trim()
if (trimmedNotes) {
lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes))
}
return truncateFormattedCrashReport(lines.join('\n'))
}
@@ -306,20 +256,13 @@ export function formatUncapturedCrashReportText(
`App version: ${context.appVersion}`,
`Platform: ${context.platform} ${context.osRelease} ${context.arch}`,
`Electron: ${context.electronVersion}`,
`Chrome: ${context.chromeVersion}`,
'',
'Details:',
'- captured_crash_report: false',
'- report_source: help_menu'
`Chrome: ${context.chromeVersion}`
]
appendUserNotesLines(lines, notes)
lines.push('', 'Details:', '- captured_crash_report: false', '- report_source: help_menu')
appendDiagnosticBundleLines(lines, diagnosticBundle, sanitizeCrashReportString)
const trimmedNotes = notes?.trim()
if (trimmedNotes) {
lines.push('', 'User notes:', sanitizeCrashReportString(trimmedNotes))
}
return truncateFormattedCrashReport(lines.join('\n'))
}
@@ -327,8 +270,7 @@ function truncateFormattedCrashReport(text: string): string {
if (text.length <= MAX_FORMATTED_REPORT_LENGTH) {
return text
}
// Why: the feedback endpoint accepts larger crash bodies and handles
// Slack-specific attachments server-side. Keep local reports below that API cap.
// The report cap leaves Slack-specific attachment handling to the feedback service.
const budget = MAX_FORMATTED_REPORT_LENGTH - FORMATTED_REPORT_TRUNCATION_SUFFIX.length
return `${text.slice(0, Math.max(0, budget)).trimEnd()}${FORMATTED_REPORT_TRUNCATION_SUFFIX}`
}