Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Browser platform: fetch transport, IndexedDB storage, window error handlers, stack-trace parser,
// and the launch() composition root. Tier 2. See docs/design/sdk-design.md §5.

// Public report-attachment input type (T3), so `UploadOptions.attachments` / `ReportHandler.before` code types without reaching into core.
export type { ReportAttachmentInput } from '@bugsee/core';
export { COMPONENT_ATTRIBUTE, componentNameFromElement } from './component-name';
export {
createUnhandledRejectionProvider,
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.bun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
export type { BugseeSpanProcessor } from '@bugsee/opentelemetry';
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.deno.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
export type { Bugsee } from '@bugsee/deno';
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.edge-light.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
export * from '@bugsee/vercel-edge';
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
// F-6: the `HttpTransport` primitive (+ its request/response option shapes), so a custom transport (a
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
// The SpanProcessor type users receive via `onOtelSpanProcessor` to register on their TracerProvider.
Expand Down
1 change: 1 addition & 0 deletions packages/bugsee/src/index.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
export * from '@bugsee/webworker';
1 change: 1 addition & 0 deletions packages/bugsee/src/index.workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ export type {
Breadcrumb,
BreadcrumbInput,
LogExceptionOptions,
ReportAttachmentInput,
UploadOptions,
} from '@bugsee/core';
181 changes: 181 additions & 0 deletions packages/core/src/attachments.test.ts
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']);
});
});
133 changes: 133 additions & 0 deletions packages/core/src/attachments.ts
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))
Comment on lines +80 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This instanceof Uint8Array check is what drops binary attachments after every JSON hop.

JSON.stringify(new Uint8Array([1,2,3])) is {"0":1,"1":2,"2":3}; Node Buffer becomes {"type":"Buffer","data":[1,2,3]}. After JSON.parse, this condition fails and the attachment is reported malformed and dropped. String data round-trips. Reproduced against this function.

That hop is not hypothetical:

  • Report markers persist JSON.stringify(marker) before assembly (node-utils report-marker-store.ts:21, browser-utils idb-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 KiB Uint8Array already expands to ~644 KiB of index-keyed JSON on that crash path.
  • Electron renderer encodeReport and WebView JSON.stringify({ source, report }) are JSON for the live path. Binary upload({attachments}) / addReportAttachment from those environments never arrives as a Uint8Array.

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.stringify a raw 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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: fileName is not type-checked, and sanitizeAttachmentFileName assumes a string (raw.replace at line 40). A non-string non-nullish value (0, false, []) skips ?? name and throws.

That throw leaves resolveUserAttachments / assembleBundle. The trigger pipeline turns it into bundle assembly failed and the incident is lost — including later valid attachments in the same array. onError is not called. Reproduced with { name: 'ok', data: 'x', fileName: 0 } followed by a good entry.

This is the hole in the “malformed → drop via onError, never throw” contract that name already honors. If fileName is present and not a string, drop this attachment through onError and continue.

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];
}
Loading
Loading