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
6 changes: 3 additions & 3 deletions src/components/CwlEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ describe('inline image helper (used by paste/drop/upload)', () => {
maxDimension: 0,
quality: 0.85,
}),
).rejects.toThrow(/exceeds/);
).rejects.toThrow(/too large to insert/);
});
});

Expand Down Expand Up @@ -363,7 +363,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => {
expect(handled).toBe(true);

await waitFor(() => expect(onImageError).toHaveBeenCalled());
expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i);
expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i);
expect(ed!.getHTML()).not.toContain('data:image');
});

Expand Down Expand Up @@ -448,7 +448,7 @@ describe('CwlEditor onImageError (paste/drop commercial path)', () => {
expect(handled).toBe(true);

await waitFor(() => expect(onImageError).toHaveBeenCalled());
expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i);
expect(String(onImageError.mock.calls[0]![0])).toMatch(/too large to insert/i);
});
});

Expand Down
8 changes: 5 additions & 3 deletions src/components/Toolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ describe('Toolbar', () => {
const italic = screen.getByRole('button', { name: /Italic/ });
const insertTable = screen.getByRole('button', { name: /^Insert table$/ });
const insertImage = screen.getByRole('button', {
name: /Insert inline \(base64\) image/,
name: /Insert inline image/,
});
const enabledButtons = (
screen.getAllByRole('button') as HTMLButtonElement[]
Expand Down Expand Up @@ -149,7 +149,7 @@ describe('Toolbar', () => {

const bold = screen.getByRole('button', { name: /Bold/ });
const insertImage = screen.getByRole('button', {
name: /Insert inline \(base64\) image/,
name: /Insert inline image/,
});
fireEvent.focus(insertImage);
expect(insertImage).toHaveAttribute('tabindex', '0');
Expand Down Expand Up @@ -276,7 +276,9 @@ describe('Toolbar', () => {
fireEvent.change(fileInput(), { target: { files: [file] } });
await waitFor(() => expect(onImageError).toHaveBeenCalled());
expect(editor.getHTML()).not.toContain('data:image');
expect(String(onImageError.mock.calls[0]![0])).toMatch(/exceeds/i);
expect(String(onImageError.mock.calls[0]![0])).toMatch(
/too large to insert/i,
);
});

it('does not throw when oversized and no onImageError is wired', async () => {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ export function Toolbar({ editor, image, onImageError }: ToolbarProps) {
onClick={() => editor.chain().focus().deleteTable().run()}
/>
<ToolbarButton
title="Insert inline (base64) image"
title="Insert inline image"
label="🖼"
onClick={() => fileInputRef.current?.click()}
/>
Expand Down
2 changes: 1 addition & 1 deletion src/converter/base64.fallbacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe('readBlobBytes environment fallbacks', () => {
vi.stubGlobal('FileReader', NullErrorReader);
const fakeBlob = { type: 'image/png' } as unknown as Blob;
await expect(blobToDataUri(fakeBlob)).rejects.toThrow(
/FileReader failed to read Blob/,
/This file couldn't be read/,
);
});

Expand Down
15 changes: 15 additions & 0 deletions src/converter/base64.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,21 @@ describe('data URI parsing & decoding', () => {
const uri = bytesToDataUri(PNG_BYTES);
expect(() => dataUriToBytes(uri, { maxBytes: 4 })).toThrow(Base64SizeError);
});
it('size guidance names the inclusive limit in exact human units', () => {
const renderLimit = (maxBytes: number): string =>
new Base64SizeError(PNG_BYTES.byteLength, maxBytes).message;
// Sub-kilobyte limits stay in bytes; whole KB and MB limits use the unit
// users reason about, so the next action ("choose a file at or below N")
// matches the inclusive size guard without mental arithmetic.
expect(() =>
bytesToDataUri(new Uint8Array(4), { maxBytes: 4 }),
).not.toThrow();
expect(renderLimit(4)).toContain('at or below 4 bytes');
expect(renderLimit(2048)).toContain('at or below 2 KB');
expect(renderLimit(3 * 1024 * 1024)).toContain('at or below 3 MB');
// Non-aligned limits fall back to exact bytes rather than rounding.
expect(renderLimit(1500)).toContain('at or below 1500 bytes');
});
});

describe('full round-trip', () => {
Expand Down
18 changes: 16 additions & 2 deletions src/converter/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,25 @@ export class Base64SizeError extends Error {
readonly maxBytes: number;
constructor(bytes: number, maxBytes: number) {
super(
`Payload of ${bytes} bytes exceeds the configured limit of ${maxBytes} bytes.`,
`This file is too large to insert. Choose a file at or below ${formatByteLimit(maxBytes)}.`,
);
this.name = 'Base64SizeError';
this.bytes = bytes;
this.maxBytes = maxBytes;
}
}

/** Render a byte limit in the largest exact unit users reason about. */
function formatByteLimit(maxBytes: number): string {
if (maxBytes >= 1024 * 1024 && maxBytes % (1024 * 1024) === 0) {
return `${maxBytes / (1024 * 1024)} MB`;
}
if (maxBytes >= 1024 && maxBytes % 1024 === 0) {
return `${maxBytes / 1024} KB`;
}
return `${maxBytes} bytes`;
}

/** Error thrown when a string is not a well-formed data URI. */
export class DataUriParseError extends Error {
constructor(message: string) {
Expand Down Expand Up @@ -224,7 +235,10 @@ async function readBlobBytes(blob: Blob): Promise<Uint8Array> {
reader.onload = () =>
resolve(new Uint8Array(reader.result as ArrayBuffer));
reader.onerror = () =>
reject(reader.error ?? new Error('FileReader failed to read Blob.'));
reject(
reader.error ??
new Error("This file couldn't be read. Try again or choose a different file."),
);
reader.readAsArrayBuffer(blob);
});
}
Expand Down
23 changes: 19 additions & 4 deletions src/extensions/Base64Image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,25 @@ export async function imageFileToInlineDataUri(
return dataUri;
}

/** Normalize a caught value to the Error contract exposed to hosts. */
function normalizeImageError(error: unknown): Error {
/* v8 ignore next -- all shipped validation and conversion paths throw Error. */
return error instanceof Error ? error : new Error('Image processing failed.');
/**
* Normalize a caught value to the Error contract exposed to hosts.
*
* Native Errors may carry actionable Inkspan guidance and public subclass
* metadata, so preserve them unchanged. `structuredClone` performs the
* platform's native Error brand check without walking an untrusted value's
* prototype chain; proxies, non-Errors, and runtimes without structured clone
* fail closed to the bounded customer-facing fallback below.
*/
export function normalizeImageError(error: unknown): Error {
try {
const cloned = structuredClone(error);
if (Object.prototype.toString.call(cloned) === '[object Error]') {
return error as Error;
}
} catch {
// Hostile proxies and unavailable structured-clone implementations fall through.
}
return new Error("This image couldn't be inserted. Try a different image file.");
}

export const Base64Image = Image.extend<Base64ImageOptions>({
Expand Down
29 changes: 29 additions & 0 deletions src/extensions/Base64ImageHostileErrorNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { normalizeImageError } from './Base64Image.js';

describe('Base64Image hostile error normalization', () => {
it('does not inspect the prototype of an untrusted rejected value', () => {
const privateSentinel = new Error('private image rejection sentinel');
let prototypeReads = 0;
const hostile = new Proxy(
{},
{
getPrototypeOf() {
prototypeReads += 1;
throw privateSentinel;
},
},
);
let normalized: Error | undefined;

expect(() => {
normalized = normalizeImageError(hostile);
}).not.toThrow();
expect(prototypeReads).toBe(0);
expect(normalized).toBeInstanceOf(Error);
expect(normalized?.message).toBe(
"This image couldn't be inserted. Try a different image file.",
);
expect(normalized?.message).not.toContain(privateSentinel.message);
});
});
20 changes: 19 additions & 1 deletion src/extensions/Base64ImageSourcePolicy.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { CwlEditorHandle } from '../types.js';
import {
Base64Image,
Base64ImageSourceError,
normalizeImageError,
validateInlineImageSource,
} from './Base64Image.js';
import { buildExtensions } from './kit.js';
Expand Down Expand Up @@ -293,4 +294,21 @@ describe('defense-in-depth image rendering', () => {
'true',
);
});
});
});
describe('host error normalization', () => {
it('passes real Error rejections through unchanged', () => {
const rejection = new Base64SizeError(10, 5);
expect(normalizeImageError(rejection)).toBe(rejection);
});

it('converts hostile non-Error rejections into actionable guidance', () => {
// Host promise chains may reject with plain strings; users still need a
// next action instead of "[object Object]".
const normalized = normalizeImageError('boom');
expect(normalized).toBeInstanceOf(Error);
expect(normalized.message).toBe(
"This image couldn't be inserted. Try a different image file.",
);
expect(normalized.message.toLowerCase()).not.toContain('boom');
});
});
4 changes: 2 additions & 2 deletions src/extensions/SafeClipboard.coverageContract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ describe('SafeClipboard fail-closed coverage contract', () => {
).toThrowError(
expect.objectContaining({
code: 'invalid_html',
message: 'Rich clipboard HTML could not be sanitized.',
message: "This content can't be inserted here. Try pasting as plain text instead.",
}),
);
});
Expand All @@ -59,7 +59,7 @@ describe('SafeClipboard fail-closed coverage contract', () => {
it('keeps the redacted sanitizer error class stable', () => {
expect(new ClipboardSanitizationError('invalid_html')).toMatchObject({
code: 'invalid_html',
message: 'Rich clipboard HTML could not be sanitized.',
message: "This content can't be inserted here. Try pasting as plain text instead.",
name: 'ClipboardSanitizationError',
});
});
Expand Down
6 changes: 3 additions & 3 deletions src/extensions/SafeClipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ describe('sanitizeRichClipboardHtml', () => {
).toThrowError(
expect.objectContaining({
code: 'input_too_large',
message: 'Rich clipboard HTML exceeds the configured byte limit.',
message: 'The pasted content is too large to insert. Try pasting less content at once.',
}),
);
});
Expand All @@ -176,7 +176,7 @@ describe('sanitizeRichClipboardHtml', () => {
).toThrowError(
expect.objectContaining({
code: 'invalid_html',
message: 'Rich clipboard HTML could not be sanitized.',
message: "This content can't be inserted here. Try pasting as plain text instead.",
}),
);
});
Expand Down Expand Up @@ -373,7 +373,7 @@ describe('SafeClipboard extension', () => {
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
code: 'invalid_html',
message: 'Rich clipboard HTML could not be sanitized.',
message: "This content can't be inserted here. Try pasting as plain text instead.",
}),
);

Expand Down
10 changes: 6 additions & 4 deletions src/extensions/SafeClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,15 @@ const ERROR_MESSAGES: Readonly<Record<ClipboardSanitizationErrorCode, string>> =
Object.freeze({
dom_unavailable:
'Rich clipboard sanitization requires a DOM-capable document.',
input_too_large: 'Rich clipboard HTML exceeds the configured byte limit.',
input_too_large:
'The pasted content is too large to insert. Try pasting less content at once.',
node_limit_exceeded:
'Rich clipboard HTML exceeds the configured node limit.',
'The pasted content is too complex to insert. Try pasting less content at once.',
depth_limit_exceeded:
'Rich clipboard HTML exceeds the configured depth limit.',
'The pasted content is too deeply nested to insert. Try pasting less content at once.',
invalid_configuration: 'Rich clipboard configuration is invalid.',
invalid_html: 'Rich clipboard HTML could not be sanitized.',
invalid_html:
"This content can't be inserted here. Try pasting as plain text instead.",
});

/** Error whose stable code and message never disclose clipboard content. */
Expand Down
2 changes: 1 addition & 1 deletion src/extensions/SafeClipboardExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe('SafeClipboard TipTap v2 adapter', () => {
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({
code: 'invalid_html',
message: 'Rich clipboard HTML could not be sanitized.',
message: "This content can't be inserted here. Try pasting as plain text instead.",
}),
);
expect(String(onError.mock.calls[0]?.[0])).not.toContain('private option');
Expand Down
2 changes: 1 addition & 1 deletion src/policy/inlineImagePolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export class Base64ImageSourceError extends Error {
constructor(source: unknown) {
const sourcePreview = redactImageSource(source);
super(
`Image source must be a strict inline base64 raster data URI (${sourcePreview}).`,
"This image format can't be inserted. Use a PNG, JPEG, GIF, WebP, AVIF, BMP, or ICO image.",
);
this.name = 'Base64ImageSourceError';
this.sourcePreview = sourcePreview;
Expand Down
Loading