Repository navigation
T3: user attachments on reports #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { | ||
| addReportAttachment, | ||
| MAX_USER_ATTACHMENT_BYTES, | ||
| MAX_USER_ATTACHMENTS, | ||
| resolveUserAttachments, | ||
| sanitizeAttachmentFileName, | ||
| } from './attachments'; | ||
| import { createReportingRequest } from './reporting'; | ||
|
|
||
| const fixedName = (): string => 'rand'; | ||
|
|
||
| describe('limits', () => { | ||
| it('mirror the mobile SDKs: 3 attachments of 3 MB', () => { | ||
| expect(MAX_USER_ATTACHMENTS).toBe(3); | ||
| expect(MAX_USER_ATTACHMENT_BYTES).toBe(3 * 1024 * 1024); | ||
| }); | ||
| }); | ||
|
|
||
| describe('sanitizeAttachmentFileName', () => { | ||
| it.each([ | ||
| ['report.txt', 'report.txt'], | ||
| ['../../etc/passwd', 'passwd'], | ||
| ['..\\..\\win\\system.ini', 'system.ini'], | ||
| ['/abs/path/a.log', 'a.log'], | ||
| ['a\u0000b\nc.txt', 'abc.txt'], | ||
| ['..', 'attachment'], | ||
| ['.', 'attachment'], | ||
| ['', 'attachment'], | ||
| ['dir/', 'attachment'], | ||
| [' ', 'attachment'], | ||
| ])('%j -> %j', (input, expected) => { | ||
| expect(sanitizeAttachmentFileName(input)).toBe(expected); | ||
| }); | ||
|
|
||
| it('caps the length at 128 characters', () => { | ||
| expect(sanitizeAttachmentFileName('a'.repeat(300))).toHaveLength(128); | ||
| }); | ||
| }); | ||
|
|
||
| describe('resolveUserAttachments', () => { | ||
| it('returns [] for undefined input', () => { | ||
| expect(resolveUserAttachments(undefined, vi.fn(), fixedName)).toEqual([]); | ||
| }); | ||
|
|
||
| it('resolves bytes and strings with defaults and a namespaced bundle file name', () => { | ||
| const bytes = new Uint8Array([1, 2, 3]); | ||
| const out = resolveUserAttachments( | ||
| [ | ||
| { name: 'blob', data: bytes }, | ||
| { name: 'note', data: 'héllo', mimeType: 'text/markdown', fileName: 'n.md' }, | ||
| { name: 'text', data: 'x' }, | ||
| ], | ||
| vi.fn(), | ||
| (() => { | ||
| let n = 0; | ||
| return () => { | ||
| n += 1; | ||
| return `r${n}`; | ||
| }; | ||
| })(), | ||
| ); | ||
| expect(out).toEqual([ | ||
| { | ||
| bundleName: 'r1.attachment.bgsfile', | ||
| name: 'blob', | ||
| mimeType: 'application/octet-stream', | ||
| fileName: 'blob', | ||
| data: bytes, | ||
| }, | ||
| { | ||
| bundleName: 'r2.attachment.bgsfile', | ||
| name: 'note', | ||
| mimeType: 'text/markdown', | ||
| fileName: 'n.md', | ||
| data: 'héllo', | ||
| }, | ||
| { | ||
| bundleName: 'r3.attachment.bgsfile', | ||
| name: 'text', | ||
| mimeType: 'text/plain', | ||
| fileName: 'text', | ||
| data: 'x', | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| it('sanitizes a traversal fileName and derives it from the name when absent', () => { | ||
| const [a, b] = resolveUserAttachments( | ||
| [ | ||
| { name: 'a', data: 'x', fileName: '../../evil.sh' }, | ||
| { name: '../b.txt', data: 'x' }, | ||
| ], | ||
| vi.fn(), | ||
| fixedName, | ||
| ); | ||
| expect(a?.fileName).toBe('evil.sh'); | ||
| expect(b?.fileName).toBe('b.txt'); | ||
| expect(a?.bundleName).not.toContain('/'); | ||
| }); | ||
|
|
||
| it('drops attachments past the third and reports each', () => { | ||
| const onError = vi.fn(); | ||
| const inputs = [1, 2, 3, 4, 5].map((n) => ({ name: `f${n}`, data: 'x' })); | ||
| const out = resolveUserAttachments(inputs, onError, fixedName); | ||
| expect(out.map((a) => a.name)).toEqual(['f1', 'f2', 'f3']); | ||
| expect(onError).toHaveBeenCalledTimes(2); | ||
| expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/f4/); | ||
| expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/at most 3/); | ||
| }); | ||
|
|
||
| it('drops an oversize attachment (bytes and utf-8 strings) without counting it against the budget', () => { | ||
| const onError = vi.fn(); | ||
| const out = resolveUserAttachments( | ||
| [ | ||
| { name: 'big', data: new Uint8Array(MAX_USER_ATTACHMENT_BYTES + 1) }, | ||
| { name: 'bigstr', data: '€'.repeat(MAX_USER_ATTACHMENT_BYTES / 3 + 1) }, | ||
| { name: 'exact', data: new Uint8Array(MAX_USER_ATTACHMENT_BYTES) }, | ||
| { name: 'ok', data: 'x' }, | ||
| ], | ||
| onError, | ||
| fixedName, | ||
| ); | ||
| expect(out.map((a) => a.name)).toEqual(['exact', 'ok']); | ||
| expect(onError).toHaveBeenCalledTimes(2); | ||
| expect((onError.mock.calls[0]?.[0] as Error).message).toMatch(/big.*3 MB/); | ||
| }); | ||
|
|
||
| it('drops malformed entries and reports them', () => { | ||
| const onError = vi.fn(); | ||
| const bad = [ | ||
| null, | ||
| 'str', | ||
| { name: '', data: 'x' }, | ||
| { name: 5, data: 'x' }, | ||
| { name: 'nodata' }, | ||
| { name: 'num', data: 42 }, | ||
| ] as never; | ||
| expect(resolveUserAttachments(bad, onError, fixedName)).toEqual([]); | ||
| expect(onError).toHaveBeenCalledTimes(6); | ||
| }); | ||
|
|
||
| it('reports a non-array input and attaches nothing', () => { | ||
| const onError = vi.fn(); | ||
| expect(resolveUserAttachments('nope' as never, onError, fixedName)).toEqual([]); | ||
| expect(onError).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('uses a random 20-char default bundle name per attachment', () => { | ||
| const [a, b] = resolveUserAttachments( | ||
| [ | ||
| { name: 'a', data: 'x' }, | ||
| { name: 'b', data: 'x' }, | ||
| ], | ||
| vi.fn(), | ||
| ); | ||
| expect(a?.bundleName).toMatch(/^[a-z0-9]{20}\.attachment\.bgsfile$/); | ||
| expect(a?.bundleName).not.toBe(b?.bundleName); | ||
| }); | ||
|
|
||
| it('never lets two attachments share a bundle name even if the generator repeats', () => { | ||
| const out = resolveUserAttachments( | ||
| [ | ||
| { name: 'a', data: 'x' }, | ||
| { name: 'b', data: 'x' }, | ||
| ], | ||
| vi.fn(), | ||
| fixedName, | ||
| ); | ||
| expect(new Set(out.map((a) => a.bundleName)).size).toBe(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe('addReportAttachment', () => { | ||
| it('appends to the report without replacing earlier ones', () => { | ||
| const { report } = createReportingRequest({ source: { type: 'code_upload' } }); | ||
| addReportAttachment(report, { name: 'a', data: 'x' }); | ||
| addReportAttachment(report, { name: 'b', data: 'y' }); | ||
| expect(report.userAttachments?.map((a) => a.name)).toEqual(['a', 'b']); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| import { utf8ByteLength } from '@bugsee/util'; | ||
| import type { Report, ReportAttachmentInput } from './reporting'; | ||
|
|
||
| // Application-supplied report attachments (Android `Report.addAttachment`, iOS `BugseeAttachment`). | ||
| // Limits mirror the mobile SDKs: at most 3 attachments of 3 MB each. An attachment that breaks a rule is | ||
| // DROPPED and reported through `onError` — a report is never lost, and the app never sees a throw, because | ||
| // the attachment usually rides a crash report that is the only record of the incident. | ||
|
|
||
| /** Mobile parity: attachments per report. */ | ||
| export const MAX_USER_ATTACHMENTS = 3; | ||
| /** Mobile parity: bytes per attachment (3 MB). */ | ||
| export const MAX_USER_ATTACHMENT_BYTES = 3 * 1024 * 1024; | ||
|
|
||
| const MAX_FILE_NAME_LENGTH = 128; | ||
| const FALLBACK_FILE_NAME = 'attachment'; | ||
| const ALPHANUMERIC = 'abcdefghijklmnopqrstuvwxyz0123456789'; | ||
| // ASCII control characters (U+0000-U+001F, U+007F); built from code points so the source has no literal controls. | ||
| const CONTROL_CHARS = new RegExp( | ||
| `[${String.fromCharCode(0)}-${String.fromCharCode(31)}${String.fromCharCode(127)}]`, | ||
| 'g', | ||
| ); | ||
|
|
||
| /** An attachment after limits/naming: ready for the assembler. */ | ||
| export interface ResolvedUserAttachment { | ||
| /** The member name inside the bundle zip: `<random20>.attachment.bgsfile` (never user-controlled). */ | ||
| bundleName: string; | ||
| /** Display label (manifest `name`). */ | ||
| name: string; | ||
| mimeType: string; | ||
| /** Sanitized download name; never contains a path separator. */ | ||
| fileName: string; | ||
| data: Uint8Array | string; | ||
| } | ||
|
|
||
| /** | ||
| * Reduce an arbitrary string to a safe single path segment: control characters removed, anything up to the | ||
| * last `/` or `\` dropped, `.`/`..`/empty replaced by a fixed fallback, capped at 128 characters. | ||
| */ | ||
| export function sanitizeAttachmentFileName(raw: string): string { | ||
| const base = (raw.replace(CONTROL_CHARS, '').split(/[\\/]/).pop() as string).trim(); | ||
| if (base === '' || base === '.' || base === '..') { | ||
| return FALLBACK_FILE_NAME; | ||
| } | ||
| return base.slice(0, MAX_FILE_NAME_LENGTH); | ||
| } | ||
|
|
||
| function randomBundleName(): string { | ||
| let name = ''; | ||
| for (let i = 0; i < 20; i += 1) { | ||
| name += ALPHANUMERIC.charAt(Math.floor(Math.random() * ALPHANUMERIC.length)); | ||
| } | ||
| return name; | ||
| } | ||
|
|
||
| function label(input: unknown): string { | ||
| const name = (input as { name?: unknown } | null)?.name; | ||
| return typeof name === 'string' && name !== '' ? `"${name}"` : '(unnamed)'; | ||
| } | ||
|
|
||
| /** | ||
| * Validate and name the application's attachments. Order is preserved; the budget is spent only by attachments | ||
| * that are accepted, so an oversize one never uses up a slot. `generateName` is injectable for tests. | ||
| */ | ||
| export function resolveUserAttachments( | ||
| inputs: readonly ReportAttachmentInput[] | undefined, | ||
| onError: (error: unknown) => void, | ||
| generateName: () => string = randomBundleName, | ||
| ): ResolvedUserAttachment[] { | ||
| if (inputs === undefined) { | ||
| return []; | ||
| } | ||
| if (!Array.isArray(inputs)) { | ||
| onError(new Error('Report attachments must be an array; none were attached')); | ||
| return []; | ||
| } | ||
| const out: ResolvedUserAttachment[] = []; | ||
| const used = new Set<string>(); | ||
| for (const input of inputs as readonly unknown[]) { | ||
| const candidate = input as Partial<ReportAttachmentInput> | null; | ||
| if ( | ||
| typeof candidate !== 'object' || | ||
| candidate === null || | ||
| typeof candidate.name !== 'string' || | ||
| candidate.name === '' || | ||
| (typeof candidate.data !== 'string' && !(candidate.data instanceof Uint8Array)) | ||
| ) { | ||
| onError( | ||
| new Error( | ||
| `Report attachment ${label(input)} is malformed (needs a name and string or Uint8Array data); dropped`, | ||
| ), | ||
| ); | ||
| continue; | ||
| } | ||
| if (out.length >= MAX_USER_ATTACHMENTS) { | ||
| onError( | ||
| new Error( | ||
| `Report attachment ${label(input)} dropped: at most ${MAX_USER_ATTACHMENTS} attachments per report`, | ||
| ), | ||
| ); | ||
| continue; | ||
| } | ||
| const { name, data } = candidate as ReportAttachmentInput; | ||
| const size = typeof data === 'string' ? utf8ByteLength(data) : data.byteLength; | ||
| if (size > MAX_USER_ATTACHMENT_BYTES) { | ||
| onError( | ||
| new Error( | ||
| `Report attachment ${label(input)} dropped: ${size} bytes exceeds the 3 MB limit`, | ||
| ), | ||
| ); | ||
| continue; | ||
| } | ||
| let bundleName = `${generateName()}.attachment.bgsfile`; | ||
| for (let n = 2; used.has(bundleName); n += 1) { | ||
| bundleName = `${generateName()}${n}.attachment.bgsfile`; | ||
| } | ||
| used.add(bundleName); | ||
| out.push({ | ||
| bundleName, | ||
| name, | ||
| mimeType: | ||
| candidate.mimeType ?? | ||
| (typeof data === 'string' ? 'text/plain' : 'application/octet-stream'), | ||
| fileName: sanitizeAttachmentFileName(candidate.fileName ?? name), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: That throw leaves This is the hole in the “malformed → drop via onError, never throw” contract that |
||
| data, | ||
| }); | ||
| } | ||
| return out; | ||
| } | ||
|
|
||
| /** Append an attachment to a report (the helper form of writing `report.userAttachments` directly). */ | ||
| export function addReportAttachment(report: Report, attachment: ReportAttachmentInput): void { | ||
| report.userAttachments = [...(report.userAttachments ?? []), attachment]; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P1: This
instanceof Uint8Arraycheck is what drops binary attachments after every JSON hop.JSON.stringify(new Uint8Array([1,2,3]))is{"0":1,"1":2,"2":3}; NodeBufferbecomes{"type":"Buffer","data":[1,2,3]}. AfterJSON.parse, this condition fails and the attachment is reported malformed and dropped. Stringdataround-trips. Reproduced against this function.That hop is not hypothetical:
JSON.stringify(marker)before assembly (node-utilsreport-marker-store.ts:21,browser-utilsidb-report-marker-store.ts:17-18). A crash that dies in the drain/zip window — the window those markers exist for — recovers the report with every binary attachment gone. A 64 KiBUint8Arrayalready expands to ~644 KiB of index-keyed JSON on that crash path.encodeReportand WebViewJSON.stringify({ source, report })are JSON for the live path. Binaryupload({attachments})/addReportAttachmentfrom those environments never arrives as aUint8Array.The comment at the top of this file says the attachment usually rides a crash report that is the only record of the incident. Encode bytes in a JSON-stable form (base64 tagged object, or a sidecar the marker points at) and decode here before this check. Do not
JSON.stringifya rawUint8Array.