From 3febb5df8e85cd6a3625882e0d217829ed20c059 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:29:41 +0900 Subject: [PATCH 01/98] test(base64): require Blob size preflight before read --- src/converter/base64.preflight.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/converter/base64.preflight.test.ts diff --git a/src/converter/base64.preflight.test.ts b/src/converter/base64.preflight.test.ts new file mode 100644 index 00000000..9e3aae91 --- /dev/null +++ b/src/converter/base64.preflight.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Base64SizeError, blobToDataUri } from './index.js'; + +describe('Blob size preflight', () => { + it('rejects an oversized Blob before reading payload bytes', async () => { + const blob = new Blob([new Uint8Array(8)], { + type: 'application/octet-stream', + }); + const readSpy = vi.spyOn(blob, 'arrayBuffer'); + + await expect(blobToDataUri(blob, { maxBytes: 4 })).rejects.toBeInstanceOf( + Base64SizeError, + ); + expect(readSpy).not.toHaveBeenCalled(); + }); +}); From 516af7198e050c721ea89112f2bf9aaa596739bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:32:38 +0900 Subject: [PATCH 02/98] test(base64): exercise jsdom Blob read boundary --- src/converter/base64.preflight.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/converter/base64.preflight.test.ts b/src/converter/base64.preflight.test.ts index 9e3aae91..3fdfb114 100644 --- a/src/converter/base64.preflight.test.ts +++ b/src/converter/base64.preflight.test.ts @@ -6,7 +6,7 @@ describe('Blob size preflight', () => { const blob = new Blob([new Uint8Array(8)], { type: 'application/octet-stream', }); - const readSpy = vi.spyOn(blob, 'arrayBuffer'); + const readSpy = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer'); await expect(blobToDataUri(blob, { maxBytes: 4 })).rejects.toBeInstanceOf( Base64SizeError, From 14652dd74e6f3000b7e963a7549f84970f7354df Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 06:36:03 +0900 Subject: [PATCH 03/98] fix(base64): preflight Blob size before materializing bytes --- src/converter/base64.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 76313e28..c9c4baa7 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -241,6 +241,7 @@ export async function blobToDataUri( blob: Blob, options: EncodeOptions = {}, ): Promise { + assertSize(blob.size, options.maxBytes); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, options.maxBytes); const mime = From 2a00a1517c8f2d9b90d59cb5ba1f3f7bc362ee7d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 07:55:48 +0900 Subject: [PATCH 04/98] test(reliability): expose oversized base64 decode allocation --- .../base64DecodeResourceBoundary.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/converter/base64DecodeResourceBoundary.test.ts diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts new file mode 100644 index 00000000..aaeb1167 --- /dev/null +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Base64SizeError, dataUriToBytes } from './index.js'; + +describe('data URI decode resource boundary', () => { + it('rejects oversized canonical base64 before decoder allocation', () => { + const decoder = vi.spyOn(globalThis.Buffer, 'from'); + let failure: unknown; + + try { + dataUriToBytes( + `data:application/octet-stream;base64,${'AAAA'.repeat(4)}`, + { maxBytes: 4 }, + ); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Base64SizeError); + expect((failure as Base64SizeError).bytes).toBe(12); + expect((failure as Base64SizeError).maxBytes).toBe(4); + expect(decoder).not.toHaveBeenCalled(); + + decoder.mockRestore(); + }); +}); From 2bc8f38ad1058bbf324172538ba2bdb13715ddf1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:00:27 +0900 Subject: [PATCH 05/98] fix(reliability): preflight canonical base64 decoded size --- src/converter/base64.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c9c4baa7..c4be5008 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -59,6 +59,8 @@ export interface ParsedDataUri { } const DATA_URI_RE = /^data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; +const CANONICAL_BASE64_RE = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -190,6 +192,16 @@ function assertSize(bytes: number, maxBytes?: number): void { } } +function canonicalBase64DecodedByteLength(payload: string): number | undefined { + if (!CANONICAL_BASE64_RE.test(payload)) return undefined; + const padding = payload.endsWith('==') + ? 2 + : payload.endsWith('=') + ? 1 + : 0; + return (payload.length / 4) * 3 - padding; +} + /** * Encode raw bytes (ArrayBuffer / typed array / Uint8Array) into a base64 * data URI. MIME is taken from `options.mimeType`, otherwise sniffed, otherwise @@ -300,6 +312,12 @@ export function dataUriToBytes( const { mimeType, isBase64, payload } = parseDataUri(dataUri); let bytes: Uint8Array; if (isBase64) { + if (typeof options.maxBytes === 'number') { + const decodedLength = canonicalBase64DecodedByteLength(payload); + if (decodedLength !== undefined) { + assertSize(decodedLength, options.maxBytes); + } + } bytes = base64ToBytes(payload); } else { // Non-base64 data URIs carry percent-encoded text. `decodeURIComponent` From e5f84f87bb10d57c7a1574bf4ab3c6deff83bf6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:02:14 +0900 Subject: [PATCH 06/98] test(reliability): cover canonical base64 size preflight --- .../base64DecodeResourceBoundary.test.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts index aaeb1167..e09eb476 100644 --- a/src/converter/base64DecodeResourceBoundary.test.ts +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { Base64SizeError, dataUriToBytes } from './index.js'; describe('data URI decode resource boundary', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('rejects oversized canonical base64 before decoder allocation', () => { const decoder = vi.spyOn(globalThis.Buffer, 'from'); let failure: unknown; @@ -19,7 +23,29 @@ describe('data URI decode resource boundary', () => { expect((failure as Base64SizeError).bytes).toBe(12); expect((failure as Base64SizeError).maxBytes).toBe(4); expect(decoder).not.toHaveBeenCalled(); + }); + + it('accounts for one canonical padding byte without changing accepted decode', () => { + expect( + dataUriToBytes('data:application/octet-stream;base64,YWI=', { + maxBytes: 2, + }).bytes, + ).toEqual(new Uint8Array([0x61, 0x62])); + }); + + it('accounts for two canonical padding bytes without changing accepted decode', () => { + expect( + dataUriToBytes('data:application/octet-stream;base64,YQ==', { + maxBytes: 1, + }).bytes, + ).toEqual(new Uint8Array([0x61])); + }); - decoder.mockRestore(); + it('preserves noncanonical whitespace-compatible fallback decoding', () => { + expect( + dataUriToBytes('data:application/octet-stream;base64,Y Q ==', { + maxBytes: 1, + }).bytes, + ).toEqual(new Uint8Array([0x61])); }); }); From 20eceaa984b4b60917e03572c147a6d0f12f5d05 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 08:12:40 +0900 Subject: [PATCH 07/98] test(reliability): expose percent decode allocation boundary --- .../base64DecodeResourceBoundary.test.ts | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts index e09eb476..2a5c86f0 100644 --- a/src/converter/base64DecodeResourceBoundary.test.ts +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Base64SizeError, dataUriToBytes } from './index.js'; +import { Base64SizeError, DataUriParseError, dataUriToBytes } from './index.js'; describe('data URI decode resource boundary', () => { afterEach(() => { @@ -48,4 +48,38 @@ describe('data URI decode resource boundary', () => { }).bytes, ).toEqual(new Uint8Array([0x61])); }); + + it('rejects oversized canonical percent-encoded ASCII before decoding', () => { + const decoder = vi.spyOn(globalThis, 'decodeURIComponent'); + let failure: unknown; + + try { + dataUriToBytes(`data:text/plain,${'%41'.repeat(8)}`, { maxBytes: 4 }); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Base64SizeError); + expect((failure as Base64SizeError).bytes).toBe(8); + expect((failure as Base64SizeError).maxBytes).toBe(4); + expect(decoder).not.toHaveBeenCalled(); + }); + + it('preserves accepted mixed literal and percent-encoded ASCII', () => { + expect( + dataUriToBytes('data:text/plain,ab%20c', { maxBytes: 4 }).bytes, + ).toEqual(new Uint8Array([0x61, 0x62, 0x20, 0x63])); + }); + + it('preserves Unicode percent-encoding fallback decoding', () => { + expect( + dataUriToBytes('data:text/plain,%C3%A9', { maxBytes: 2 }).bytes, + ).toEqual(new Uint8Array([0xc3, 0xa9])); + }); + + it('preserves malformed percent-encoding error precedence', () => { + expect(() => + dataUriToBytes('data:text/plain,%ZZ', { maxBytes: 0 }), + ).toThrow(DataUriParseError); + }); }); From bc4e14fb6c03806328cb576a94e71efd9c8ad3e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:09:41 +0900 Subject: [PATCH 08/98] test(reliability): isolate percent decode allocation RED --- src/converter/base64DecodeResourceBoundary.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts index 2a5c86f0..c5c1f86e 100644 --- a/src/converter/base64DecodeResourceBoundary.test.ts +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -67,14 +67,18 @@ describe('data URI decode resource boundary', () => { it('preserves accepted mixed literal and percent-encoded ASCII', () => { expect( - dataUriToBytes('data:text/plain,ab%20c', { maxBytes: 4 }).bytes, - ).toEqual(new Uint8Array([0x61, 0x62, 0x20, 0x63])); + Array.from( + dataUriToBytes('data:text/plain,ab%20c', { maxBytes: 4 }).bytes, + ), + ).toEqual([0x61, 0x62, 0x20, 0x63]); }); it('preserves Unicode percent-encoding fallback decoding', () => { expect( - dataUriToBytes('data:text/plain,%C3%A9', { maxBytes: 2 }).bytes, - ).toEqual(new Uint8Array([0xc3, 0xa9])); + Array.from( + dataUriToBytes('data:text/plain,%C3%A9', { maxBytes: 2 }).bytes, + ), + ).toEqual([0xc3, 0xa9]); }); it('preserves malformed percent-encoding error precedence', () => { From 817061d3a3e053a041c2a3acd3121901bcf93063 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 13:10:50 +0900 Subject: [PATCH 09/98] fix(reliability): preflight percent decode allocation --- src/converter/base64.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c4be5008..64cbf635 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -61,6 +61,8 @@ export interface ParsedDataUri { const DATA_URI_RE = /^data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; const CANONICAL_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const CANONICAL_PERCENT_ENCODED_ASCII_RE = + /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -202,6 +204,20 @@ function canonicalBase64DecodedByteLength(payload: string): number | undefined { return (payload.length / 4) * 3 - padding; } +function canonicalPercentEncodedAsciiDecodedByteLength( + payload: string, +): number | undefined { + if (!CANONICAL_PERCENT_ENCODED_ASCII_RE.test(payload)) return undefined; + let escapeCount = 0; + for (let index = 0; index < payload.length; index += 1) { + if (payload.charCodeAt(index) === 0x25) { + escapeCount += 1; + index += 2; + } + } + return payload.length - escapeCount * 2; +} + /** * Encode raw bytes (ArrayBuffer / typed array / Uint8Array) into a base64 * data URI. MIME is taken from `options.mimeType`, otherwise sniffed, otherwise @@ -320,6 +336,12 @@ export function dataUriToBytes( } bytes = base64ToBytes(payload); } else { + if (typeof options.maxBytes === 'number') { + const decodedLength = canonicalPercentEncodedAsciiDecodedByteLength(payload); + if (decodedLength !== undefined) { + assertSize(decodedLength, options.maxBytes); + } + } // Non-base64 data URIs carry percent-encoded text. `decodeURIComponent` // throws a raw `URIError` on malformed escapes (e.g. `%`, `%ZZ`); surface // the module's documented `DataUriParseError` instead so callers guarding From 5de317a8cfb080155354f1ff2b664d37950dee65 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:41:37 +0900 Subject: [PATCH 10/98] test(converter): prove runtime maxBytes fails closed --- src/converter/base64RuntimeOptions.test.ts | 56 ++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/converter/base64RuntimeOptions.test.ts diff --git a/src/converter/base64RuntimeOptions.test.ts b/src/converter/base64RuntimeOptions.test.ts new file mode 100644 index 00000000..6ce421e3 --- /dev/null +++ b/src/converter/base64RuntimeOptions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from 'vitest'; +import { blobToDataUri, bytesToDataUri, dataUriToBytes } from './index.js'; + +const INVALID_MAX_BYTES = [ + -1, + Number.NaN, + Number.POSITIVE_INFINITY, + 0.5, + Number.MAX_SAFE_INTEGER + 1, + '4', +] as const; + +function runtimeOptions(maxBytes: unknown): { maxBytes?: number } { + return { maxBytes } as { maxBytes?: number }; +} + +describe('base64 converter runtime maxBytes contract', () => { + it.each(INVALID_MAX_BYTES)( + 'rejects invalid maxBytes %j before encoding raw bytes', + (maxBytes) => { + expect(() => + bytesToDataUri(new Uint8Array([0x61]), runtimeOptions(maxBytes)), + ).toThrowError(new RangeError('maxBytes must be a non-negative safe integer.')); + }, + ); + + it('rejects an invalid Blob maxBytes before reading payload bytes', async () => { + const reader = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer'); + + await expect( + blobToDataUri(new Blob([new Uint8Array([0x61])]), runtimeOptions(Number.NaN)), + ).rejects.toThrowError( + new RangeError('maxBytes must be a non-negative safe integer.'), + ); + expect(reader).not.toHaveBeenCalled(); + }); + + it('rejects an invalid decode maxBytes before parsing or decoding payload text', () => { + const decoder = vi.spyOn(globalThis.Buffer, 'from'); + + expect(() => + dataUriToBytes( + 'data:application/octet-stream;base64,YQ==', + runtimeOptions(Number.POSITIVE_INFINITY), + ), + ).toThrowError(new RangeError('maxBytes must be a non-negative safe integer.')); + expect(decoder).not.toHaveBeenCalled(); + }); + + it('preserves zero and finite safe-integer ceilings', () => { + expect(bytesToDataUri(new Uint8Array(), { maxBytes: 0 })).toContain(';base64,'); + expect(() => + dataUriToBytes('data:text/plain,a', { maxBytes: 1 }), + ).not.toThrow(); + }); +}); From a389a4f7398e64469d98785cffda4a71c786d0f1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:46:15 +0900 Subject: [PATCH 11/98] fix(converter): validate runtime maxBytes limits --- src/converter/base64.ts | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 64cbf635..6db756da 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -194,6 +194,18 @@ function assertSize(bytes: number, maxBytes?: number): void { } } +function resolveMaxBytes(maxBytes: unknown): number | undefined { + if (maxBytes === undefined) return undefined; + if ( + typeof maxBytes !== 'number' || + !Number.isSafeInteger(maxBytes) || + maxBytes < 0 + ) { + throw new RangeError('maxBytes must be a non-negative safe integer.'); + } + return maxBytes; +} + function canonicalBase64DecodedByteLength(payload: string): number | undefined { if (!CANONICAL_BASE64_RE.test(payload)) return undefined; const padding = payload.endsWith('==') @@ -227,8 +239,9 @@ export function bytesToDataUri( input: ArrayBuffer | ArrayBufferView | Uint8Array, options: EncodeOptions = {}, ): string { + const maxBytes = resolveMaxBytes(options.maxBytes); const bytes = toUint8Array(input); - assertSize(bytes.byteLength, options.maxBytes); + assertSize(bytes.byteLength, maxBytes); const mime = options.mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; @@ -269,9 +282,10 @@ export async function blobToDataUri( blob: Blob, options: EncodeOptions = {}, ): Promise { - assertSize(blob.size, options.maxBytes); + const maxBytes = resolveMaxBytes(options.maxBytes); + assertSize(blob.size, maxBytes); const bytes = await readBlobBytes(blob); - assertSize(bytes.byteLength, options.maxBytes); + assertSize(bytes.byteLength, maxBytes); const mime = options.mimeType || (blob.type && blob.type.length > 0 ? blob.type : undefined) || @@ -325,21 +339,22 @@ export function dataUriToBytes( dataUri: string, options: { maxBytes?: number } = {}, ): DecodedDataUri { + const maxBytes = resolveMaxBytes(options.maxBytes); const { mimeType, isBase64, payload } = parseDataUri(dataUri); let bytes: Uint8Array; if (isBase64) { - if (typeof options.maxBytes === 'number') { + if (maxBytes !== undefined) { const decodedLength = canonicalBase64DecodedByteLength(payload); if (decodedLength !== undefined) { - assertSize(decodedLength, options.maxBytes); + assertSize(decodedLength, maxBytes); } } bytes = base64ToBytes(payload); } else { - if (typeof options.maxBytes === 'number') { + if (maxBytes !== undefined) { const decodedLength = canonicalPercentEncodedAsciiDecodedByteLength(payload); if (decodedLength !== undefined) { - assertSize(decodedLength, options.maxBytes); + assertSize(decodedLength, maxBytes); } } // Non-base64 data URIs carry percent-encoded text. `decodeURIComponent` @@ -356,7 +371,7 @@ export function dataUriToBytes( } bytes = new TextEncoder().encode(decoded); } - assertSize(bytes.byteLength, options.maxBytes); + assertSize(bytes.byteLength, maxBytes); return { mimeType, bytes }; } From f715194a3a88195de441bc7270d487c0c6109c02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:51:32 +0900 Subject: [PATCH 12/98] test(converter): prove option containers fail closed --- src/converter/base64RuntimeOptions.test.ts | 80 ++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/converter/base64RuntimeOptions.test.ts b/src/converter/base64RuntimeOptions.test.ts index 6ce421e3..9242ca73 100644 --- a/src/converter/base64RuntimeOptions.test.ts +++ b/src/converter/base64RuntimeOptions.test.ts @@ -54,3 +54,83 @@ describe('base64 converter runtime maxBytes contract', () => { ).not.toThrow(); }); }); + +describe('base64 converter runtime option containers', () => { + const invalidOptions = () => + new RangeError('converter options are invalid.'); + + it.each([ + null, + [], + { maxByte: 0 }, + { [Symbol('unknown')]: 0 }, + Object.create({ maxBytes: 0 }) as object, + ])('rejects malformed encode option containers without coercion', (options) => { + expect(() => + bytesToDataUri( + new Uint8Array([0x61]), + options as unknown as { maxBytes?: number }, + ), + ).toThrowError(invalidOptions()); + }); + + it('rejects accessor-backed maxBytes without invoking the getter', () => { + let reads = 0; + const options = {} as { maxBytes?: number }; + Object.defineProperty(options, 'maxBytes', { + enumerable: true, + get() { + reads += 1; + throw new Error('private maxBytes getter failure'); + }, + }); + + expect(() => + bytesToDataUri(new Uint8Array([0x61]), options), + ).toThrowError(invalidOptions()); + expect(reads).toBe(0); + }); + + it('rejects non-enumerable option properties', () => { + const options = {} as { maxBytes?: number }; + Object.defineProperty(options, 'maxBytes', { + enumerable: false, + value: 1, + }); + + expect(() => + bytesToDataUri(new Uint8Array([0x61]), options), + ).toThrowError(invalidOptions()); + }); + + it('rejects malformed decode option containers before URI parsing', () => { + expect(() => + dataUriToBytes('not-a-data-uri', { + maxByte: 0, + } as unknown as { maxBytes?: number }), + ).toThrowError(invalidOptions()); + }); + + it('rejects non-string runtime MIME overrides', () => { + expect(() => + bytesToDataUri(new Uint8Array([0x61]), { + mimeType: 7, + } as unknown as { mimeType?: string }), + ).toThrowError(new RangeError('mimeType must be a string.')); + }); + + it('preserves exact data-property values on null-prototype option objects', () => { + const options = Object.create(null) as { + mimeType?: string; + maxBytes?: number; + }; + Object.defineProperties(options, { + mimeType: { enumerable: true, value: 'application/x-thing' }, + maxBytes: { enumerable: true, value: 1 }, + }); + + expect(bytesToDataUri(new Uint8Array([0x61]), options)).toBe( + 'data:application/x-thing;base64,YQ==', + ); + }); +}); From dc160fe99793d630391546a231993de9746ae67c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 14:54:59 +0900 Subject: [PATCH 13/98] fix(converter): validate exact runtime option shapes --- src/converter/base64.ts | 70 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 64 insertions(+), 6 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 6db756da..4d6a48ef 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -63,6 +63,7 @@ const CANONICAL_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; +const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -206,6 +207,64 @@ function resolveMaxBytes(maxBytes: unknown): number | undefined { return maxBytes; } +function readRuntimeOptions( + options: unknown, + allowedKeys: readonly string[], +): Record { + try { + if ( + typeof options !== 'object' || + options === null || + Array.isArray(options) + ) { + throw new TypeError('invalid options container'); + } + + const prototype = Object.getPrototypeOf(options); + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('invalid options prototype'); + } + + const resolved: Record = Object.create(null) as Record< + string, + unknown + >; + for (const key of Reflect.ownKeys(options)) { + if (typeof key !== 'string' || !allowedKeys.includes(key)) { + throw new TypeError('unknown option'); + } + const descriptor = Object.getOwnPropertyDescriptor(options, key) as PropertyDescriptor; + if (!descriptor.enumerable || !('value' in descriptor)) { + throw new TypeError('invalid option property'); + } + resolved[key] = descriptor.value as unknown; + } + return resolved; + } catch { + throw new RangeError(INVALID_OPTIONS_MESSAGE); + } +} + +function resolveEncodeOptions(options: unknown): { + mimeType: string | undefined; + maxBytes: number | undefined; +} { + const values = readRuntimeOptions(options, ['mimeType', 'maxBytes']); + const mimeType = values.mimeType; + if (mimeType !== undefined && typeof mimeType !== 'string') { + throw new RangeError('mimeType must be a string.'); + } + return { + mimeType, + maxBytes: resolveMaxBytes(values.maxBytes), + }; +} + +function resolveDecodeMaxBytes(options: unknown): number | undefined { + const values = readRuntimeOptions(options, ['maxBytes']); + return resolveMaxBytes(values.maxBytes); +} + function canonicalBase64DecodedByteLength(payload: string): number | undefined { if (!CANONICAL_BASE64_RE.test(payload)) return undefined; const padding = payload.endsWith('==') @@ -239,11 +298,10 @@ export function bytesToDataUri( input: ArrayBuffer | ArrayBufferView | Uint8Array, options: EncodeOptions = {}, ): string { - const maxBytes = resolveMaxBytes(options.maxBytes); + const { mimeType, maxBytes } = resolveEncodeOptions(options); const bytes = toUint8Array(input); assertSize(bytes.byteLength, maxBytes); - const mime = - options.mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; + const mime = mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; } @@ -282,12 +340,12 @@ export async function blobToDataUri( blob: Blob, options: EncodeOptions = {}, ): Promise { - const maxBytes = resolveMaxBytes(options.maxBytes); + const { mimeType, maxBytes } = resolveEncodeOptions(options); assertSize(blob.size, maxBytes); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, maxBytes); const mime = - options.mimeType || + mimeType || (blob.type && blob.type.length > 0 ? blob.type : undefined) || sniffMimeType(bytes) || 'application/octet-stream'; @@ -339,7 +397,7 @@ export function dataUriToBytes( dataUri: string, options: { maxBytes?: number } = {}, ): DecodedDataUri { - const maxBytes = resolveMaxBytes(options.maxBytes); + const maxBytes = resolveDecodeMaxBytes(options); const { mimeType, isBase64, payload } = parseDataUri(dataUri); let bytes: Uint8Array; if (isBase64) { From 2d680230ea94b9e2c0a64647760020933ea99b8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:20:51 +0900 Subject: [PATCH 14/98] test(converter): expose unbounded MIME override metadata --- .../base64MimeResourceBoundary.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/converter/base64MimeResourceBoundary.test.ts diff --git a/src/converter/base64MimeResourceBoundary.test.ts b/src/converter/base64MimeResourceBoundary.test.ts new file mode 100644 index 00000000..2ed43057 --- /dev/null +++ b/src/converter/base64MimeResourceBoundary.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; +import { blobToDataUri, bytesToDataUri } from './index.js'; + +const MAX_MIME_TYPE_CODE_UNITS = 1_024; +const MIME_LIMIT_ERROR = new RangeError( + 'mimeType must not exceed 1024 UTF-16 code units.', +); + +function oversizedMimeType(): string { + return 'x'.repeat(MAX_MIME_TYPE_CODE_UNITS + 1); +} + +describe('converter MIME override resource boundary', () => { + it('rejects oversized explicit MIME metadata before output materialization', () => { + expect(() => + bytesToDataUri(new Uint8Array(), { mimeType: oversizedMimeType() }), + ).toThrowError(MIME_LIMIT_ERROR); + }); + + it('rejects an oversized Blob MIME override before reading payload bytes', async () => { + const reader = vi.spyOn(FileReader.prototype, 'readAsArrayBuffer'); + + try { + await expect( + blobToDataUri(new Blob([new Uint8Array([0x61])]), { + mimeType: oversizedMimeType(), + }), + ).rejects.toThrowError(MIME_LIMIT_ERROR); + expect(reader).not.toHaveBeenCalled(); + } finally { + reader.mockRestore(); + } + }); + + it('preserves explicit MIME metadata at the exact local ceiling', () => { + const mimeType = 'x'.repeat(MAX_MIME_TYPE_CODE_UNITS); + + expect(bytesToDataUri(new Uint8Array(), { mimeType })).toBe( + `data:${mimeType};base64,`, + ); + }); +}); From 61855b90a5e55defa6020272b5b1cee1b5fa7352 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:27:30 +0900 Subject: [PATCH 15/98] fix(converter): bound MIME override metadata --- src/converter/base64.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 4d6a48ef..84229883 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -64,6 +64,7 @@ const CANONICAL_BASE64_RE = const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; +const MAX_MIME_TYPE_CODE_UNITS = 1_024; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -254,6 +255,12 @@ function resolveEncodeOptions(options: unknown): { if (mimeType !== undefined && typeof mimeType !== 'string') { throw new RangeError('mimeType must be a string.'); } + if ( + typeof mimeType === 'string' && + mimeType.length > MAX_MIME_TYPE_CODE_UNITS + ) { + throw new RangeError('mimeType must not exceed 1024 UTF-16 code units.'); + } return { mimeType, maxBytes: resolveMaxBytes(values.maxBytes), @@ -448,4 +455,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From 0ae8e06194cc682044888b159a779d63e65ae459 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:56:56 +0900 Subject: [PATCH 16/98] test(converter): expose coercible non-binary inputs --- .../base64BinaryInputRuntime.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/converter/base64BinaryInputRuntime.test.ts diff --git a/src/converter/base64BinaryInputRuntime.test.ts b/src/converter/base64BinaryInputRuntime.test.ts new file mode 100644 index 00000000..1e7d6765 --- /dev/null +++ b/src/converter/base64BinaryInputRuntime.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { bytesToDataUri, toUint8Array } from './index.js'; + +const INVALID_BINARY_INPUT = { + name: 'TypeError', + message: 'converter binary input is invalid.', +}; + +function hostileArrayLike(): { + value: object; + wasLengthRead: () => boolean; +} { + let lengthRead = false; + const value = Object.create(null) as Record; + Object.defineProperty(value, 'length', { + enumerable: true, + configurable: true, + get() { + lengthRead = true; + return 1; + }, + }); + value[0] = 0x61; + return { value, wasLengthRead: () => lengthRead }; +} + +function captureFailure(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + return undefined; +} + +describe('converter binary input runtime boundary', () => { + it('rejects coercible array-like objects before Uint8Array construction', () => { + const hostile = hostileArrayLike(); + + const failure = captureFailure(() => toUint8Array(hostile.value as never)); + + expect(hostile.wasLengthRead()).toBe(false); + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); + + it('rejects coercible array-like objects before data-URI encoding', () => { + const hostile = hostileArrayLike(); + + const failure = captureFailure(() => + bytesToDataUri(hostile.value as never), + ); + + expect(hostile.wasLengthRead()).toBe(false); + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); +}); From 757eb6616edc2ee41a25019842b872644c244f98 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:03:33 +0900 Subject: [PATCH 17/98] fix(converter): reject coercible non-binary inputs --- src/converter/base64.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 84229883..d5a5e473 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -64,7 +64,12 @@ const CANONICAL_BASE64_RE = const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; +const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; const MAX_MIME_TYPE_CODE_UNITS = 1_024; +const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + ArrayBuffer.prototype, + 'byteLength', +)!.get!; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -106,7 +111,17 @@ export function base64ToBytes(base64: string): Uint8Array { return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); } -/** Coerce any binary-ish input to a `Uint8Array` view without copying twice. */ +/** Return whether a value carries the platform ArrayBuffer internal slot. */ +function isArrayBuffer(input: unknown): input is ArrayBuffer { + try { + ARRAY_BUFFER_BYTE_LENGTH_GETTER.call(input); + return true; + } catch { + return false; + } +} + +/** Convert only declared binary inputs to a `Uint8Array` without coercion. */ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { @@ -114,7 +129,8 @@ export function toUint8Array( if (ArrayBuffer.isView(input)) { return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); } - return new Uint8Array(input); + if (isArrayBuffer(input)) return new Uint8Array(input); + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); } /** @@ -455,4 +471,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From 38b18fcccb94bbcee348bde664ada39c73e63839 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 21:33:12 +0900 Subject: [PATCH 18/98] test(converter): reject Blob-like runtime impostors before property access --- src/converter/base64BlobRuntimeInput.test.ts | 37 ++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/converter/base64BlobRuntimeInput.test.ts diff --git a/src/converter/base64BlobRuntimeInput.test.ts b/src/converter/base64BlobRuntimeInput.test.ts new file mode 100644 index 00000000..3bbf4c92 --- /dev/null +++ b/src/converter/base64BlobRuntimeInput.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; +import { blobToDataUri } from './base64.js'; + +describe('blobToDataUri runtime Blob boundary', () => { + it('rejects non-Blob values before reading caller-controlled Blob-like properties', async () => { + let sizeReads = 0; + let typeReads = 0; + const hostileBlobLike = Object.create(null) as Record; + + Object.defineProperties(hostileBlobLike, { + size: { + configurable: true, + get() { + sizeReads += 1; + throw new Error('caller-controlled size getter executed'); + }, + }, + type: { + configurable: true, + get() { + typeReads += 1; + throw new Error('caller-controlled type getter executed'); + }, + }, + [Symbol.toStringTag]: { + configurable: true, + value: 'Blob', + }, + }); + + await expect( + blobToDataUri(hostileBlobLike as unknown as Blob), + ).rejects.toThrowError(TypeError); + expect(sizeReads).toBe(0); + expect(typeReads).toBe(0); + }); +}); From a94399df0dd48138c23d472fd1b13f10d73fbd44 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 01:21:49 +0900 Subject: [PATCH 19/98] fix(converter): validate Blob runtime brand before property access --- src/converter/base64.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index d5a5e473..3ce1c17b 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -65,11 +65,16 @@ const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; +const INVALID_BLOB_INPUT_MESSAGE = 'converter Blob input is invalid.'; const MAX_MIME_TYPE_CODE_UNITS = 1_024; const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, 'byteLength', )!.get!; +const BLOB_SIZE_GETTER = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'size', +)!.get!; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -121,6 +126,15 @@ function isArrayBuffer(input: unknown): input is ArrayBuffer { } } +/** Return the Blob byte length only after the platform internal-slot check. */ +function readBlobSize(input: unknown): number { + try { + return BLOB_SIZE_GETTER.call(input) as number; + } catch { + throw new TypeError(INVALID_BLOB_INPUT_MESSAGE); + } +} + /** Convert only declared binary inputs to a `Uint8Array` without coercion. */ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, @@ -364,7 +378,7 @@ export async function blobToDataUri( options: EncodeOptions = {}, ): Promise { const { mimeType, maxBytes } = resolveEncodeOptions(options); - assertSize(blob.size, maxBytes); + assertSize(readBlobSize(blob), maxBytes); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, maxBytes); const mime = From ff2ab2eeaa0d0ed92e0e36d33207364ea90a27bc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:02:41 +0900 Subject: [PATCH 20/98] test(converter): preserve Blob fallbacks with real runtime values --- src/converter/base64.fallbacks.test.ts | 37 ++++++++++++++------------ 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 13586cb2..1deffde2 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -29,21 +29,23 @@ describe('readBlobBytes environment fallbacks', () => { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); + const withoutArrayBuffer = (blob: Blob): Blob => { + Object.defineProperty(blob, 'arrayBuffer', { + configurable: true, + value: undefined, + }); + return blob; + }; + it('uses Blob.arrayBuffer when the blob implements it', async () => { - const fakeBlob = { - type: 'application/octet-stream', - arrayBuffer: () => Promise.resolve(BYTES.buffer.slice(0)), - } as unknown as Blob; - const uri = await blobToDataUri(fakeBlob); + const blob = new Blob([BYTES], { type: 'application/octet-stream' }); + const uri = await blobToDataUri(blob); expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); }); it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { - const fakeBlob = { - type: '', - arrayBuffer: () => Promise.resolve(new Uint8Array([0, 1, 2, 3]).buffer.slice(0)), - } as unknown as Blob; - const uri = await blobToDataUri(fakeBlob); + const blob = new Blob([new Uint8Array([0, 1, 2, 3])]); + const uri = await blobToDataUri(blob); expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); }); @@ -58,9 +60,10 @@ describe('readBlobBytes environment fallbacks', () => { } } vi.stubGlobal('FileReader', FailingReader); - // No `arrayBuffer` method -> the FileReader branch is taken. - const fakeBlob = { type: 'image/png' } as unknown as Blob; - await expect(blobToDataUri(fakeBlob)).rejects.toThrow('reader boom'); + // Shadow `arrayBuffer` on a genuine Blob so only the environment capability + // changes; the runtime Blob-brand contract remains realistic. + const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); + await expect(blobToDataUri(blob)).rejects.toThrow('reader boom'); }); it('rejects with a synthesized error when FileReader has no error object', async () => { @@ -74,8 +77,8 @@ describe('readBlobBytes environment fallbacks', () => { } } vi.stubGlobal('FileReader', NullErrorReader); - const fakeBlob = { type: 'image/png' } as unknown as Blob; - await expect(blobToDataUri(fakeBlob)).rejects.toThrow( + const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); + await expect(blobToDataUri(blob)).rejects.toThrow( /FileReader failed to read Blob/, ); }); @@ -91,8 +94,8 @@ describe('readBlobBytes environment fallbacks', () => { } }, ); - const fakeBlob = { type: 'image/png' } as unknown as Blob; - const uri = await blobToDataUri(fakeBlob); + const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); + const uri = await blobToDataUri(blob); expect(uri.startsWith('data:image/png;base64,')).toBe(true); }); }); From 601675ff73344393790e07cda9adcc39c39c8b90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 04:07:45 +0900 Subject: [PATCH 21/98] test(converter): cover real Blob arrayBuffer capability --- src/converter/base64.fallbacks.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 1deffde2..b55c2981 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -39,6 +39,10 @@ describe('readBlobBytes environment fallbacks', () => { it('uses Blob.arrayBuffer when the blob implements it', async () => { const blob = new Blob([BYTES], { type: 'application/octet-stream' }); + Object.defineProperty(blob, 'arrayBuffer', { + configurable: true, + value: () => Promise.resolve(BYTES.buffer.slice(0)), + }); const uri = await blobToDataUri(blob); expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); }); From 130c3c17b52f86952aa42d71ccb4feaaff579f02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 15:39:34 +0900 Subject: [PATCH 22/98] test(converter): prove Blob MIME accessor authority boundary --- src/converter/base64BlobTypeMetadata.test.ts | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/converter/base64BlobTypeMetadata.test.ts diff --git a/src/converter/base64BlobTypeMetadata.test.ts b/src/converter/base64BlobTypeMetadata.test.ts new file mode 100644 index 00000000..53d6f598 --- /dev/null +++ b/src/converter/base64BlobTypeMetadata.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { blobToDataUri } from './base64.js'; + +describe('blobToDataUri platform MIME metadata authority', () => { + it('does not evaluate a caller-overridden Blob type accessor', async () => { + let typeAccessorRead = false; + + class CallerControlledTypeBlob extends Blob { + override get type(): string { + typeAccessorRead = true; + return 'application/x-caller-controlled'; + } + } + + const blob = new CallerControlledTypeBlob( + [new Uint8Array([1, 2, 3])], + { type: 'application/octet-stream' }, + ); + + const uri = await blobToDataUri(blob); + + expect(typeAccessorRead).toBe(false); + expect(uri).toBe('data:application/octet-stream;base64,AQID'); + }); +}); From 75b5ca112f8d6a59fac76ef047bb8d52204715e0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 18:06:57 +0900 Subject: [PATCH 23/98] fix(converter): trust platform Blob MIME metadata --- src/converter/base64.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 3ce1c17b..465f4ddc 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -75,6 +75,10 @@ const BLOB_SIZE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'size', )!.get!; +const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'type', +)!.get!; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -135,6 +139,11 @@ function readBlobSize(input: unknown): number { } } +/** Return platform Blob MIME metadata without invoking caller overrides. */ +function readBlobType(input: Blob): string { + return BLOB_TYPE_GETTER.call(input) as string; +} + /** Convert only declared binary inputs to a `Uint8Array` without coercion. */ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, @@ -381,9 +390,10 @@ export async function blobToDataUri( assertSize(readBlobSize(blob), maxBytes); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, maxBytes); + const blobType = readBlobType(blob); const mime = mimeType || - (blob.type && blob.type.length > 0 ? blob.type : undefined) || + (blobType.length > 0 ? blobType : undefined) || sniffMimeType(bytes) || 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; @@ -485,4 +495,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From 58156307f47841dbafe1479d06868ab63ee03b02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:36:20 +0900 Subject: [PATCH 24/98] test(converter): reject hostile data URI runtime inputs --- .../base64DataUriRuntimeInput.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/converter/base64DataUriRuntimeInput.test.ts diff --git a/src/converter/base64DataUriRuntimeInput.test.ts b/src/converter/base64DataUriRuntimeInput.test.ts new file mode 100644 index 00000000..7bfe6c70 --- /dev/null +++ b/src/converter/base64DataUriRuntimeInput.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { DataUriParseError, dataUriToBytes, parseDataUri } from './base64.js'; + +describe('data URI runtime input authority', () => { + it('rejects non-string parse input without evaluating caller trim behavior', () => { + let trimRead = false; + const hostile = Object.defineProperty({}, 'trim', { + get() { + trimRead = true; + throw new Error('private trim getter'); + }, + }); + + expect(() => parseDataUri(hostile as unknown as string)).toThrow( + DataUriParseError, + ); + expect(trimRead).toBe(false); + }); + + it('rejects non-string decode input without invoking caller trim behavior', () => { + let trimCalled = false; + const hostile = { + trim() { + trimCalled = true; + return 'data:text/plain,forged'; + }, + }; + + expect(() => dataUriToBytes(hostile as unknown as string)).toThrow( + DataUriParseError, + ); + expect(trimCalled).toBe(false); + }); +}); From d05b4ba7083b7683008fd47a4ee6ac4f7d6137f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:40:11 +0900 Subject: [PATCH 25/98] fix(converter): validate data URI runtime input --- src/converter/base64.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 465f4ddc..3c9a6b89 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -412,6 +412,9 @@ export function fileToDataUri( * Throws `DataUriParseError` on malformed input. */ export function parseDataUri(dataUri: string): ParsedDataUri { + if (typeof dataUri !== 'string') { + throw new DataUriParseError('String is not a valid data URI.'); + } const match = DATA_URI_RE.exec(dataUri.trim()); if (!match) { throw new DataUriParseError('String is not a valid data URI.'); @@ -495,4 +498,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From 3de7b4f511a5819c608fc56e736f586578e203fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:48:21 +0900 Subject: [PATCH 26/98] test(converter): reject hostile data URI predicate inputs --- .../base64DataUriRuntimeInput.test.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/src/converter/base64DataUriRuntimeInput.test.ts b/src/converter/base64DataUriRuntimeInput.test.ts index 7bfe6c70..a3c0fc30 100644 --- a/src/converter/base64DataUriRuntimeInput.test.ts +++ b/src/converter/base64DataUriRuntimeInput.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { DataUriParseError, dataUriToBytes, parseDataUri } from './base64.js'; +import { + DataUriParseError, + dataUriToBytes, + isDataUri, + parseDataUri, +} from './base64.js'; describe('data URI runtime input authority', () => { it('rejects non-string parse input without evaluating caller trim behavior', () => { @@ -31,4 +36,30 @@ describe('data URI runtime input authority', () => { ); expect(trimCalled).toBe(false); }); + + it('returns false for non-string predicate input without evaluating caller trim behavior', () => { + let trimRead = false; + const hostile = Object.defineProperty({}, 'trim', { + get() { + trimRead = true; + throw new Error('private trim getter'); + }, + }); + + expect(isDataUri(hostile as unknown as string)).toBe(false); + expect(trimRead).toBe(false); + }); + + it('does not accept a non-string predicate input with a forged trim result', () => { + let trimCalled = false; + const hostile = { + trim() { + trimCalled = true; + return 'data:text/plain,forged'; + }, + }; + + expect(isDataUri(hostile as unknown as string)).toBe(false); + expect(trimCalled).toBe(false); + }); }); From 408fc5c9ee4b37bb0673de77e346cc64d8033316 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 22:51:51 +0900 Subject: [PATCH 27/98] fix(converter): validate data URI predicate runtime input --- src/converter/base64.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 3c9a6b89..b30befb9 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -430,7 +430,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { /** `true` when the string is a syntactically valid data URI. */ export function isDataUri(value: string): boolean { - return DATA_URI_RE.test(value.trim()); + return typeof value === 'string' && DATA_URI_RE.test(value.trim()); } export interface DecodedDataUri { From 67d54106753d05a4866a5d544efb6838ad514852 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:09:28 +0900 Subject: [PATCH 28/98] test(converter): reject non-string base64 before member access --- .../base64PrimitiveRuntimeInput.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 src/converter/base64PrimitiveRuntimeInput.test.ts diff --git a/src/converter/base64PrimitiveRuntimeInput.test.ts b/src/converter/base64PrimitiveRuntimeInput.test.ts new file mode 100644 index 00000000..584a87f8 --- /dev/null +++ b/src/converter/base64PrimitiveRuntimeInput.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; + +import { base64ToBytes } from './index.js'; + +const INVALID_BASE64_INPUT_MESSAGE = 'base64 input must be a string.'; + +describe('base64 primitive runtime input', () => { + it('rejects a non-string before reading caller-controlled replace', () => { + let replaceReads = 0; + const hostile = Object.create(null) as Record; + Object.defineProperty(hostile, 'replace', { + configurable: true, + enumerable: true, + get() { + replaceReads += 1; + throw new Error('private replace getter must not execute'); + }, + }); + + expect(() => base64ToBytes(hostile as unknown as string)).toThrowError( + new TypeError(INVALID_BASE64_INPUT_MESSAGE), + ); + expect(replaceReads).toBe(0); + }); + + it('rejects primitive non-strings with the same stable boundary error', () => { + expect(() => base64ToBytes(123 as unknown as string)).toThrowError( + new TypeError(INVALID_BASE64_INPUT_MESSAGE), + ); + }); +}); From b290a048253e438f2e999e49bd3e1bd95c4ef403 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:13:34 +0900 Subject: [PATCH 29/98] fix(converter): validate base64 primitive runtime input --- src/converter/base64.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index b30befb9..516a4dda 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -66,6 +66,7 @@ const CANONICAL_PERCENT_ENCODED_ASCII_RE = const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; const INVALID_BLOB_INPUT_MESSAGE = 'converter Blob input is invalid.'; +const INVALID_BASE64_INPUT_MESSAGE = 'base64 input must be a string.'; const MAX_MIME_TYPE_CODE_UNITS = 1_024; const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, @@ -105,6 +106,9 @@ export function bytesToBase64(bytes: Uint8Array): string { /** Decode a base64 string to raw bytes. Works in Node and the browser. */ export function base64ToBytes(base64: string): Uint8Array { + if (typeof base64 !== 'string') { + throw new TypeError(INVALID_BASE64_INPUT_MESSAGE); + } const normalized = base64.replace(/\s+/g, ''); /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ if (!hasBuffer) { @@ -498,4 +502,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From 560300b5c804fc629f6689114a8d3d2a5aea44b2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:20:01 +0900 Subject: [PATCH 30/98] test(converter): reject malformed forgiving base64 deterministically --- src/converter/base64ForgivingDecode.test.ts | 44 +++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/converter/base64ForgivingDecode.test.ts diff --git a/src/converter/base64ForgivingDecode.test.ts b/src/converter/base64ForgivingDecode.test.ts new file mode 100644 index 00000000..ed75cbf1 --- /dev/null +++ b/src/converter/base64ForgivingDecode.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { base64ToBytes, dataUriToBytes } from './index.js'; + +const INVALID_BASE64_MESSAGE = 'String is not valid base64 data.'; + +function expectInvalidBase64(decode: () => unknown): void { + let caught: unknown; + try { + decode(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).name).toBe('Base64ParseError'); + expect((caught as Error).message).toBe(INVALID_BASE64_MESSAGE); +} + +describe('WHATWG forgiving-base64 decode boundary', () => { + it('rejects invalid alphabet before the environment decoder', () => { + expectInvalidBase64(() => base64ToBytes('!!!!')); + expectInvalidBase64(() => + dataUriToBytes('data:application/octet-stream;base64,!!!!'), + ); + }); + + it('rejects impossible length and invalid padding', () => { + expectInvalidBase64(() => base64ToBytes('A')); + expectInvalidBase64(() => base64ToBytes('aGVsbG8===')); + }); + + it('removes only ASCII whitespace and rejects non-ASCII whitespace', () => { + const asciiWhitespace = 'aG\tV\ns\fb\rG 8='; + expect(new TextDecoder().decode(base64ToBytes(asciiWhitespace))).toBe( + 'hello', + ); + expectInvalidBase64(() => base64ToBytes('aG\u00a0VsbG8=')); + }); + + it('preserves valid padded and unpadded forgiving-base64 input', () => { + expect(new TextDecoder().decode(base64ToBytes('aGVsbG8='))).toBe('hello'); + expect(new TextDecoder().decode(base64ToBytes('aGVsbG8'))).toBe('hello'); + }); +}); From 9a1846ee6163bb59daaf1b9f73b7dbbba5815199 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:24:11 +0900 Subject: [PATCH 31/98] fix(converter): validate forgiving base64 deterministically --- src/converter/base64.ts | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 516a4dda..47467574 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -27,6 +27,14 @@ export class Base64SizeError extends Error { } } +/** Error thrown when a string is not valid forgiving-base64 data. */ +export class Base64ParseError extends Error { + constructor() { + super('String is not valid base64 data.'); + this.name = 'Base64ParseError'; + } +} + /** Error thrown when a string is not a well-formed data URI. */ export class DataUriParseError extends Error { constructor(message: string) { @@ -61,6 +69,7 @@ export interface ParsedDataUri { const DATA_URI_RE = /^data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; const CANONICAL_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; +const FORGIVING_BASE64_ALPHABET_RE = /^[A-Za-z0-9+/]*$/; const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; @@ -104,12 +113,38 @@ export function bytesToBase64(bytes: Uint8Array): string { ).toString('base64'); } +/** + * Normalize and validate the exact input accepted by WHATWG forgiving-base64. + * Validation happens before either environment-specific decoder runs so Node + * and browsers expose the same deterministic acceptance boundary. + */ +function normalizeForgivingBase64(base64: string): string { + let normalized = base64.replace(/[\t\n\f\r ]+/g, ''); + + if (normalized.length % 4 === 0) { + if (normalized.endsWith('==')) { + normalized = normalized.slice(0, -2); + } else if (normalized.endsWith('=')) { + normalized = normalized.slice(0, -1); + } + } + + if ( + normalized.length % 4 === 1 || + !FORGIVING_BASE64_ALPHABET_RE.test(normalized) + ) { + throw new Base64ParseError(); + } + + return normalized; +} + /** Decode a base64 string to raw bytes. Works in Node and the browser. */ export function base64ToBytes(base64: string): Uint8Array { if (typeof base64 !== 'string') { throw new TypeError(INVALID_BASE64_INPUT_MESSAGE); } - const normalized = base64.replace(/\s+/g, ''); + const normalized = normalizeForgivingBase64(base64); /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ if (!hasBuffer) { // eslint-disable-next-line no-undef @@ -502,4 +537,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From c06cb7256746843090bf66b209c6ac2c62e33e96 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 08:24:42 +0900 Subject: [PATCH 32/98] fix(converter): export malformed base64 error --- src/converter/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/converter/index.ts b/src/converter/index.ts index 68e9ff19..c7e00a36 100644 --- a/src/converter/index.ts +++ b/src/converter/index.ts @@ -10,6 +10,7 @@ */ export { Base64SizeError, + Base64ParseError, DataUriParseError, bytesToBase64, base64ToBytes, From 41297650421884a2b58fe673b3081cbf80f7b57a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:05:04 +0900 Subject: [PATCH 33/98] test(converter): reject prototype-spoofed typed arrays --- .../base64BinaryInputRuntime.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/converter/base64BinaryInputRuntime.test.ts b/src/converter/base64BinaryInputRuntime.test.ts index 1e7d6765..19d1b076 100644 --- a/src/converter/base64BinaryInputRuntime.test.ts +++ b/src/converter/base64BinaryInputRuntime.test.ts @@ -24,6 +24,23 @@ function hostileArrayLike(): { return { value, wasLengthRead: () => lengthRead }; } +function prototypeSpoofedUint8Array(): { + value: object; + wasByteLengthRead: () => boolean; +} { + let byteLengthRead = false; + const value = Object.create(Uint8Array.prototype) as Record; + Object.defineProperty(value, 'byteLength', { + enumerable: false, + configurable: true, + get() { + byteLengthRead = true; + return 0; + }, + }); + return { value, wasByteLengthRead: () => byteLengthRead }; +} + function captureFailure(run: () => unknown): unknown { try { run(); @@ -53,4 +70,24 @@ describe('converter binary input runtime boundary', () => { expect(hostile.wasLengthRead()).toBe(false); expect(failure).toMatchObject(INVALID_BINARY_INPUT); }); + + it('rejects prototype-spoofed Uint8Array values instead of accepting instanceof alone', () => { + const hostile = prototypeSpoofedUint8Array(); + + const failure = captureFailure(() => toUint8Array(hostile.value as never)); + + expect(hostile.wasByteLengthRead()).toBe(false); + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); + + it('rejects prototype-spoofed Uint8Array values before caller-member access', () => { + const hostile = prototypeSpoofedUint8Array(); + + const failure = captureFailure(() => + bytesToDataUri(hostile.value as never), + ); + + expect(hostile.wasByteLengthRead()).toBe(false); + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); }); From 1d13b4763b328934ef47223cbd2f11bc37a00a90 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 15:08:58 +0900 Subject: [PATCH 34/98] fix(converter): reject typed-array prototype spoofing --- src/converter/base64.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 47467574..3924bdbd 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -187,8 +187,8 @@ function readBlobType(input: Blob): string { export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { - if (input instanceof Uint8Array) return input; if (ArrayBuffer.isView(input)) { + if (input instanceof Uint8Array) return input; return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); } if (isArrayBuffer(input)) return new Uint8Array(input); @@ -537,4 +537,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From afe39a5238b30eafa65dd642ec6a889da7811e68 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 23:12:22 +0900 Subject: [PATCH 35/98] test(converter): guard base64 byte runtime input --- src/converter/base64ByteRuntimeInput.test.ts | 61 ++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/converter/base64ByteRuntimeInput.test.ts diff --git a/src/converter/base64ByteRuntimeInput.test.ts b/src/converter/base64ByteRuntimeInput.test.ts new file mode 100644 index 00000000..aaa2c6a0 --- /dev/null +++ b/src/converter/base64ByteRuntimeInput.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; + +import { bytesToBase64 } from './index.js'; + +const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; + +describe('bytesToBase64 runtime byte input', () => { + it('rejects a byte-array impostor before reading caller-controlled members', () => { + let bufferReads = 0; + const hostile = Object.create(null) as Record; + Object.defineProperty(hostile, 'buffer', { + configurable: true, + enumerable: true, + get() { + bufferReads += 1; + throw new Error('private-byte-buffer-sentinel'); + }, + }); + + expect(() => bytesToBase64(hostile as unknown as Uint8Array)).toThrowError( + new TypeError(INVALID_BINARY_INPUT_MESSAGE), + ); + expect(bufferReads).toBe(0); + }); + + it('uses platform byte-array slots instead of shadowed range accessors', () => { + const bytes = new Uint8Array([0x66, 0x6f, 0x6f]); + let bufferReads = 0; + let byteOffsetReads = 0; + let byteLengthReads = 0; + + Object.defineProperties(bytes, { + buffer: { + configurable: true, + get() { + bufferReads += 1; + throw new Error('private-byte-buffer-sentinel'); + }, + }, + byteOffset: { + configurable: true, + get() { + byteOffsetReads += 1; + throw new Error('private-byte-offset-sentinel'); + }, + }, + byteLength: { + configurable: true, + get() { + byteLengthReads += 1; + throw new Error('private-byte-length-sentinel'); + }, + }, + }); + + expect(bytesToBase64(bytes)).toBe('Zm9v'); + expect(bufferReads).toBe(0); + expect(byteOffsetReads).toBe(0); + expect(byteLengthReads).toBe(0); + }); +}); From 551641cd57aced8d272565af182484ee96256f72 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 01:15:32 +0900 Subject: [PATCH 36/98] fix(converter): validate base64 byte runtime input --- src/converter/base64.ts | 51 +++++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 3924bdbd..4e53b56a 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -81,6 +81,23 @@ const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, 'byteLength', )!.get!; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype) as object; +const TYPED_ARRAY_BUFFER_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'buffer', +)!.get!; +const TYPED_ARRAY_BYTE_OFFSET_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteOffset', +)!.get!; +const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + 'byteLength', +)!.get!; +const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor( + TYPED_ARRAY_PROTOTYPE, + Symbol.toStringTag, +)!.get!; const BLOB_SIZE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'size', @@ -92,25 +109,45 @@ const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( const hasBuffer = typeof globalThis.Buffer !== 'undefined'; +interface Uint8ArraySlots { + buffer: ArrayBufferLike; + byteOffset: number; + byteLength: number; +} + +/** Read genuine Uint8Array slots without evaluating caller-owned properties. */ +function readUint8ArraySlots(input: unknown): Uint8ArraySlots { + try { + if (TYPED_ARRAY_TAG_GETTER.call(input) !== 'Uint8Array') { + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); + } + return { + buffer: TYPED_ARRAY_BUFFER_GETTER.call(input) as ArrayBufferLike, + byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(input) as number, + byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(input) as number, + }; + } catch { + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); + } +} + /** Encode raw bytes to a base64 string. Works in Node and the browser. */ export function bytesToBase64(bytes: Uint8Array): string { + const { buffer, byteOffset, byteLength } = readUint8ArraySlots(bytes); + const view = new Uint8Array(buffer, byteOffset, byteLength); /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ if (!hasBuffer) { let binary = ''; const chunkSize = 0x8000; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, i + chunkSize); + for (let i = 0; i < view.length; i += chunkSize) { + const chunk = view.subarray(i, i + chunkSize); binary += String.fromCharCode(...chunk); } // eslint-disable-next-line no-undef return btoa(binary); } /* v8 ignore stop */ - return globalThis.Buffer.from( - bytes.buffer, - bytes.byteOffset, - bytes.byteLength, - ).toString('base64'); + return globalThis.Buffer.from(buffer, byteOffset, byteLength).toString('base64'); } /** From e16e455210c3290e69cf3cc746d68d1ac6db4702 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:08:57 +0900 Subject: [PATCH 37/98] test(converter): reject byte-view member interposition --- src/converter/base64ByteRuntimeInput.test.ts | 23 +++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/converter/base64ByteRuntimeInput.test.ts b/src/converter/base64ByteRuntimeInput.test.ts index aaa2c6a0..836b8a13 100644 --- a/src/converter/base64ByteRuntimeInput.test.ts +++ b/src/converter/base64ByteRuntimeInput.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { bytesToBase64 } from './index.js'; +import { bytesToBase64, bytesToDataUri } from './index.js'; const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; @@ -58,4 +58,25 @@ describe('bytesToBase64 runtime byte input', () => { expect(byteOffsetReads).toBe(0); expect(byteLengthReads).toBe(0); }); + + it('does not re-enter shadowed byte-length metadata in data-URI encoding', () => { + const bytes = new Uint8Array([0x66, 0x6f, 0x6f]); + let byteLengthReads = 0; + + Object.defineProperty(bytes, 'byteLength', { + configurable: true, + get() { + byteLengthReads += 1; + throw new Error('private-byte-length-sentinel'); + }, + }); + + expect( + bytesToDataUri(bytes, { + mimeType: 'application/octet-stream', + maxBytes: 3, + }), + ).toBe('data:application/octet-stream;base64,Zm9v'); + expect(byteLengthReads).toBe(0); + }); }); From b850a44f6ef67098051a556dcae37d819f52fc18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 06:13:46 +0900 Subject: [PATCH 38/98] fix(converter): read data-uri byte length from platform slots --- src/converter/base64.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 4e53b56a..d27b73bf 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -422,7 +422,8 @@ export function bytesToDataUri( ): string { const { mimeType, maxBytes } = resolveEncodeOptions(options); const bytes = toUint8Array(input); - assertSize(bytes.byteLength, maxBytes); + const { byteLength } = readUint8ArraySlots(bytes); + assertSize(byteLength, maxBytes); const mime = mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; } @@ -574,4 +575,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From 40caf27835dde78f58e3bd888eeeeb4d5fba6f3c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:01:46 +0900 Subject: [PATCH 39/98] test(converter): expose DataView accessor interposition --- src/converter/base64ViewRuntimeInput.test.ts | 32 ++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/converter/base64ViewRuntimeInput.test.ts diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts new file mode 100644 index 00000000..fcba28e2 --- /dev/null +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { toUint8Array } from './index.js'; + +describe('converter binary-view runtime boundary', () => { + it('does not evaluate caller-overridden DataView byte-range accessors', () => { + const privateSentinel = new Error('private DataView accessor sentinel'); + const readCallerAccessor = vi.fn((): never => { + throw privateSentinel; + }); + + class HostileDataView extends DataView { + override get buffer(): ArrayBuffer { + return readCallerAccessor(); + } + + override get byteOffset(): number { + return readCallerAccessor(); + } + + override get byteLength(): number { + return readCallerAccessor(); + } + } + + const source = new Uint8Array([1, 2, 3, 4]); + const view = new HostileDataView(source.buffer, 1, 2); + + expect(Array.from(toUint8Array(view))).toEqual([2, 3]); + expect(readCallerAccessor).not.toHaveBeenCalled(); + }); +}); From 73e2393ce71a0abcb7e7092a05ece620e2f87231 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:03:16 +0900 Subject: [PATCH 40/98] test(converter): typecheck hostile DataView regression --- src/converter/base64ViewRuntimeInput.test.ts | 23 ++++++-------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts index fcba28e2..8443e52c 100644 --- a/src/converter/base64ViewRuntimeInput.test.ts +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -8,23 +8,14 @@ describe('converter binary-view runtime boundary', () => { const readCallerAccessor = vi.fn((): never => { throw privateSentinel; }); - - class HostileDataView extends DataView { - override get buffer(): ArrayBuffer { - return readCallerAccessor(); - } - - override get byteOffset(): number { - return readCallerAccessor(); - } - - override get byteLength(): number { - return readCallerAccessor(); - } - } - const source = new Uint8Array([1, 2, 3, 4]); - const view = new HostileDataView(source.buffer, 1, 2); + const view = new DataView(source.buffer, 1, 2); + + Object.defineProperties(view, { + buffer: { get: readCallerAccessor }, + byteOffset: { get: readCallerAccessor }, + byteLength: { get: readCallerAccessor }, + }); expect(Array.from(toUint8Array(view))).toEqual([2, 3]); expect(readCallerAccessor).not.toHaveBeenCalled(); From 45f89a9903f44a205ad2a5b8389d3a7cfdb21ff1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:06:31 +0900 Subject: [PATCH 41/98] test(converter): cover hostile typed-array accessors --- src/converter/base64ViewRuntimeInput.test.ts | 23 ++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts index 8443e52c..ae2dab1c 100644 --- a/src/converter/base64ViewRuntimeInput.test.ts +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -3,13 +3,11 @@ import { describe, expect, it, vi } from 'vitest'; import { toUint8Array } from './index.js'; describe('converter binary-view runtime boundary', () => { - it('does not evaluate caller-overridden DataView byte-range accessors', () => { - const privateSentinel = new Error('private DataView accessor sentinel'); + function shadowByteRangeAccessors(view: ArrayBufferView) { + const privateSentinel = new Error('private binary-view accessor sentinel'); const readCallerAccessor = vi.fn((): never => { throw privateSentinel; }); - const source = new Uint8Array([1, 2, 3, 4]); - const view = new DataView(source.buffer, 1, 2); Object.defineProperties(view, { buffer: { get: readCallerAccessor }, @@ -17,7 +15,24 @@ describe('converter binary-view runtime boundary', () => { byteLength: { get: readCallerAccessor }, }); + return readCallerAccessor; + } + + it('does not evaluate caller-overridden DataView byte-range accessors', () => { + const source = new Uint8Array([1, 2, 3, 4]); + const view = new DataView(source.buffer, 1, 2); + const readCallerAccessor = shadowByteRangeAccessors(view); + expect(Array.from(toUint8Array(view))).toEqual([2, 3]); expect(readCallerAccessor).not.toHaveBeenCalled(); }); + + it('does not evaluate caller-overridden typed-array byte-range accessors', () => { + const source = new Uint8Array([1, 2, 3, 4]); + const view = new Uint16Array(source.buffer, 2, 1); + const readCallerAccessor = shadowByteRangeAccessors(view); + + expect(Array.from(toUint8Array(view))).toEqual([3, 4]); + expect(readCallerAccessor).not.toHaveBeenCalled(); + }); }); From ab98ce8f2a6a7161216058362801d4c928854096 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:07:49 +0900 Subject: [PATCH 42/98] fix(converter): read binary-view slots intrinsically --- src/converter/base64.ts | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index d27b73bf..e96b0179 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -98,6 +98,18 @@ const TYPED_ARRAY_TAG_GETTER = Object.getOwnPropertyDescriptor( TYPED_ARRAY_PROTOTYPE, Symbol.toStringTag, )!.get!; +const DATA_VIEW_BUFFER_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'buffer', +)!.get!; +const DATA_VIEW_BYTE_OFFSET_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'byteOffset', +)!.get!; +const DATA_VIEW_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( + DataView.prototype, + 'byteLength', +)!.get!; const BLOB_SIZE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'size', @@ -131,6 +143,22 @@ function readUint8ArraySlots(input: unknown): Uint8ArraySlots { } } +/** Read a genuine ArrayBuffer view's byte range without caller-owned accessors. */ +function readArrayBufferViewSlots(input: ArrayBufferView): Uint8ArraySlots { + if (TYPED_ARRAY_TAG_GETTER.call(input) !== undefined) { + return { + buffer: TYPED_ARRAY_BUFFER_GETTER.call(input) as ArrayBufferLike, + byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(input) as number, + byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(input) as number, + }; + } + return { + buffer: DATA_VIEW_BUFFER_GETTER.call(input) as ArrayBufferLike, + byteOffset: DATA_VIEW_BYTE_OFFSET_GETTER.call(input) as number, + byteLength: DATA_VIEW_BYTE_LENGTH_GETTER.call(input) as number, + }; +} + /** Encode raw bytes to a base64 string. Works in Node and the browser. */ export function bytesToBase64(bytes: Uint8Array): string { const { buffer, byteOffset, byteLength } = readUint8ArraySlots(bytes); @@ -226,7 +254,8 @@ export function toUint8Array( ): Uint8Array { if (ArrayBuffer.isView(input)) { if (input instanceof Uint8Array) return input; - return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + const { buffer, byteOffset, byteLength } = readArrayBufferViewSlots(input); + return new Uint8Array(buffer, byteOffset, byteLength); } if (isArrayBuffer(input)) return new Uint8Array(input); throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); From 29d2cce184afbe3e397bab6b4722f1cb27284452 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:13:03 +0900 Subject: [PATCH 43/98] test(converter): expose MIME sniff member interposition --- src/converter/sniffMimeRuntimeInput.test.ts | 42 +++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/converter/sniffMimeRuntimeInput.test.ts diff --git a/src/converter/sniffMimeRuntimeInput.test.ts b/src/converter/sniffMimeRuntimeInput.test.ts new file mode 100644 index 00000000..af938d59 --- /dev/null +++ b/src/converter/sniffMimeRuntimeInput.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { sniffMimeType } from './index.js'; + +describe('MIME sniffing runtime binary boundary', () => { + it('rejects non-byte impostors before caller-controlled member access', () => { + const privateSentinel = new Error('private MIME impostor sentinel'); + const readLength = vi.fn((): never => { + throw privateSentinel; + }); + const hostile = Object.defineProperty({}, 'length', { + get: readLength, + }); + + expect(() => sniffMimeType(hostile as unknown as Uint8Array)).toThrow( + TypeError, + ); + expect(readLength).not.toHaveBeenCalled(); + }); + + it('does not evaluate caller-overridden Uint8Array members', () => { + const bytes = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + ]); + const privateSentinel = new Error('private MIME byte-array sentinel'); + const readLength = vi.fn((): never => { + throw privateSentinel; + }); + const callSubarray = vi.fn((): never => { + throw privateSentinel; + }); + + Object.defineProperties(bytes, { + length: { get: readLength }, + subarray: { value: callSubarray }, + }); + + expect(sniffMimeType(bytes)).toBe('image/png'); + expect(readLength).not.toHaveBeenCalled(); + expect(callSubarray).not.toHaveBeenCalled(); + }); +}); From 5c1ac3d6853bb1c57470808adb50ee720c0f0fdc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 16 Aug 2026 09:16:42 +0900 Subject: [PATCH 44/98] fix(converter): harden MIME sniff byte boundary --- src/converter/base64.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index e96b0179..55e7833b 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -267,7 +267,8 @@ export function toUint8Array( * their own fallback. */ export function sniffMimeType(bytes: Uint8Array): string | undefined { - const b = bytes; + const { buffer, byteOffset, byteLength } = readUint8ArraySlots(bytes); + const b = new Uint8Array(buffer, byteOffset, byteLength); if (b.length >= 8) { // PNG: 89 50 4E 47 0D 0A 1A 0A if ( From 8eabd33f0565315b92d98791d2d69c31daf29cbf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:03:31 +0900 Subject: [PATCH 45/98] test(converter): reject detached Uint8Array inputs --- .../base64BinaryInputRuntime.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/converter/base64BinaryInputRuntime.test.ts b/src/converter/base64BinaryInputRuntime.test.ts index 19d1b076..3418a4f8 100644 --- a/src/converter/base64BinaryInputRuntime.test.ts +++ b/src/converter/base64BinaryInputRuntime.test.ts @@ -41,6 +41,13 @@ function prototypeSpoofedUint8Array(): { return { value, wasByteLengthRead: () => byteLengthRead }; } +function detachedUint8Array(): Uint8Array { + const buffer = new ArrayBuffer(4); + const view = new Uint8Array(buffer); + structuredClone(buffer, { transfer: [buffer] }); + return view; +} + function captureFailure(run: () => unknown): unknown { try { run(); @@ -90,4 +97,16 @@ describe('converter binary input runtime boundary', () => { expect(hostile.wasByteLengthRead()).toBe(false); expect(failure).toMatchObject(INVALID_BINARY_INPUT); }); + + it('rejects detached Uint8Array values at the conversion boundary', () => { + const failure = captureFailure(() => toUint8Array(detachedUint8Array())); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); + + it('normalizes detached Uint8Array failures before data-URI encoding', () => { + const failure = captureFailure(() => bytesToDataUri(detachedUint8Array())); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); }); From 6a501221d4a16d65e1117e999c33c0d4e2d2529d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:07:11 +0900 Subject: [PATCH 46/98] fix(converter): reject detached Uint8Array inputs --- src/converter/base64.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 55e7833b..f11f3ba1 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -143,6 +143,17 @@ function readUint8ArraySlots(input: unknown): Uint8ArraySlots { } } +/** Reject detached Uint8Array storage before exposing it to later consumers. */ +function assertUsableUint8Array(input: Uint8Array): void { + const { buffer, byteOffset, byteLength } = readUint8ArraySlots(input); + try { + const probe = new Uint8Array(buffer, byteOffset, byteLength); + void probe; + } catch { + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); + } +} + /** Read a genuine ArrayBuffer view's byte range without caller-owned accessors. */ function readArrayBufferViewSlots(input: ArrayBufferView): Uint8ArraySlots { if (TYPED_ARRAY_TAG_GETTER.call(input) !== undefined) { @@ -253,7 +264,10 @@ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { if (ArrayBuffer.isView(input)) { - if (input instanceof Uint8Array) return input; + if (input instanceof Uint8Array) { + assertUsableUint8Array(input); + return input; + } const { buffer, byteOffset, byteLength } = readArrayBufferViewSlots(input); return new Uint8Array(buffer, byteOffset, byteLength); } From e79b11847a0d1e84eee5c51739f165aae9990ab5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:10:16 +0900 Subject: [PATCH 47/98] test(converter): normalize detached direct encoder input --- src/converter/base64BinaryInputRuntime.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/converter/base64BinaryInputRuntime.test.ts b/src/converter/base64BinaryInputRuntime.test.ts index 3418a4f8..93ed6cf4 100644 --- a/src/converter/base64BinaryInputRuntime.test.ts +++ b/src/converter/base64BinaryInputRuntime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { bytesToDataUri, toUint8Array } from './index.js'; +import { bytesToBase64, bytesToDataUri, toUint8Array } from './index.js'; const INVALID_BINARY_INPUT = { name: 'TypeError', @@ -109,4 +109,10 @@ describe('converter binary input runtime boundary', () => { expect(failure).toMatchObject(INVALID_BINARY_INPUT); }); + + it('normalizes detached Uint8Array failures before direct base64 encoding', () => { + const failure = captureFailure(() => bytesToBase64(detachedUint8Array())); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); }); From 957bd0d0ca3d2eafcb346a1ce597341f19889f69 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:15:15 +0900 Subject: [PATCH 48/98] fix(converter): normalize detached byte views at slot boundary --- src/converter/base64.ts | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index f11f3ba1..4b4c513f 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -127,28 +127,24 @@ interface Uint8ArraySlots { byteLength: number; } -/** Read genuine Uint8Array slots without evaluating caller-owned properties. */ +/** Read genuine live Uint8Array slots without evaluating caller-owned properties. */ function readUint8ArraySlots(input: unknown): Uint8ArraySlots { try { if (TYPED_ARRAY_TAG_GETTER.call(input) !== 'Uint8Array') { throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); } - return { + const slots = { buffer: TYPED_ARRAY_BUFFER_GETTER.call(input) as ArrayBufferLike, byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(input) as number, byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(input) as number, }; - } catch { - throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); - } -} - -/** Reject detached Uint8Array storage before exposing it to later consumers. */ -function assertUsableUint8Array(input: Uint8Array): void { - const { buffer, byteOffset, byteLength } = readUint8ArraySlots(input); - try { - const probe = new Uint8Array(buffer, byteOffset, byteLength); + const probe = new Uint8Array( + slots.buffer, + slots.byteOffset, + slots.byteLength, + ); void probe; + return slots; } catch { throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); } @@ -265,7 +261,7 @@ export function toUint8Array( ): Uint8Array { if (ArrayBuffer.isView(input)) { if (input instanceof Uint8Array) { - assertUsableUint8Array(input); + readUint8ArraySlots(input); return input; } const { buffer, byteOffset, byteLength } = readArrayBufferViewSlots(input); From fe987703502bd02cc860e87141d533c00966db71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 05:19:10 +0900 Subject: [PATCH 49/98] test(converter): normalize detached binary views --- src/converter/base64ViewRuntimeInput.test.ts | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts index ae2dab1c..9415eef2 100644 --- a/src/converter/base64ViewRuntimeInput.test.ts +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -2,6 +2,11 @@ import { describe, expect, it, vi } from 'vitest'; import { toUint8Array } from './index.js'; +const INVALID_BINARY_INPUT = { + name: 'TypeError', + message: 'converter binary input is invalid.', +}; + describe('converter binary-view runtime boundary', () => { function shadowByteRangeAccessors(view: ArrayBufferView) { const privateSentinel = new Error('private binary-view accessor sentinel'); @@ -18,6 +23,19 @@ describe('converter binary-view runtime boundary', () => { return readCallerAccessor; } + function detach(buffer: ArrayBuffer): void { + structuredClone(buffer, { transfer: [buffer] }); + } + + function captureFailure(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + return undefined; + } + it('does not evaluate caller-overridden DataView byte-range accessors', () => { const source = new Uint8Array([1, 2, 3, 4]); const view = new DataView(source.buffer, 1, 2); @@ -35,4 +53,24 @@ describe('converter binary-view runtime boundary', () => { expect(Array.from(toUint8Array(view))).toEqual([3, 4]); expect(readCallerAccessor).not.toHaveBeenCalled(); }); + + it('normalizes detached DataView failures before range reconstruction', () => { + const buffer = new ArrayBuffer(4); + const view = new DataView(buffer, 1, 2); + detach(buffer); + + const failure = captureFailure(() => toUint8Array(view)); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); + + it('normalizes detached non-byte typed-array failures before range reconstruction', () => { + const buffer = new ArrayBuffer(4); + const view = new Uint16Array(buffer, 0, 2); + detach(buffer); + + const failure = captureFailure(() => toUint8Array(view)); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); }); From 038edff9d773f8e03a2fbbcbc8cad4acefe3b719 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:23:05 +0900 Subject: [PATCH 50/98] fix(converter): normalize detached ArrayBuffer views --- src/converter/base64.ts | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 4b4c513f..c9946aed 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -150,20 +150,30 @@ function readUint8ArraySlots(input: unknown): Uint8ArraySlots { } } -/** Read a genuine ArrayBuffer view's byte range without caller-owned accessors. */ +/** Read a genuine live ArrayBuffer view's range without caller-owned accessors. */ function readArrayBufferViewSlots(input: ArrayBufferView): Uint8ArraySlots { - if (TYPED_ARRAY_TAG_GETTER.call(input) !== undefined) { - return { - buffer: TYPED_ARRAY_BUFFER_GETTER.call(input) as ArrayBufferLike, - byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(input) as number, - byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(input) as number, - }; + try { + const slots = TYPED_ARRAY_TAG_GETTER.call(input) !== undefined + ? { + buffer: TYPED_ARRAY_BUFFER_GETTER.call(input) as ArrayBufferLike, + byteOffset: TYPED_ARRAY_BYTE_OFFSET_GETTER.call(input) as number, + byteLength: TYPED_ARRAY_BYTE_LENGTH_GETTER.call(input) as number, + } + : { + buffer: DATA_VIEW_BUFFER_GETTER.call(input) as ArrayBufferLike, + byteOffset: DATA_VIEW_BYTE_OFFSET_GETTER.call(input) as number, + byteLength: DATA_VIEW_BYTE_LENGTH_GETTER.call(input) as number, + }; + const probe = new Uint8Array( + slots.buffer, + slots.byteOffset, + slots.byteLength, + ); + void probe; + return slots; + } catch { + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); } - return { - buffer: DATA_VIEW_BUFFER_GETTER.call(input) as ArrayBufferLike, - byteOffset: DATA_VIEW_BYTE_OFFSET_GETTER.call(input) as number, - byteLength: DATA_VIEW_BYTE_LENGTH_GETTER.call(input) as number, - }; } /** Encode raw bytes to a base64 string. Works in Node and the browser. */ From c117ded469405a3f4c9b039491bdb9d8bdadccd0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:28:17 +0900 Subject: [PATCH 51/98] test(converter): reject Blob byte-reader interposition --- src/converter/base64BlobRuntimeInput.test.ts | 22 +++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/converter/base64BlobRuntimeInput.test.ts b/src/converter/base64BlobRuntimeInput.test.ts index 3bbf4c92..9e2c2a8b 100644 --- a/src/converter/base64BlobRuntimeInput.test.ts +++ b/src/converter/base64BlobRuntimeInput.test.ts @@ -34,4 +34,24 @@ describe('blobToDataUri runtime Blob boundary', () => { expect(sizeReads).toBe(0); expect(typeReads).toBe(0); }); -}); + + it('does not evaluate a genuine Blob instance arrayBuffer override', async () => { + let arrayBufferReads = 0; + const blob = new Blob([new Uint8Array([1, 2, 3])], { + type: 'application/octet-stream', + }); + + Object.defineProperty(blob, 'arrayBuffer', { + configurable: true, + get() { + arrayBufferReads += 1; + throw new Error('private Blob byte-reader sentinel'); + }, + }); + + await expect(blobToDataUri(blob)).resolves.toBe( + 'data:application/octet-stream;base64,AQID', + ); + expect(arrayBufferReads).toBe(0); + }); +}); \ No newline at end of file From f771ffe3356488c6f57616d1187734ef0bc29d02 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:32:16 +0900 Subject: [PATCH 52/98] test(converter): model platform Blob reader fallback --- src/converter/base64.fallbacks.test.ts | 61 ++++++++++++++++---------- 1 file changed, 37 insertions(+), 24 deletions(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index b55c2981..4ec0c54a 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -29,22 +29,31 @@ describe('readBlobBytes environment fallbacks', () => { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); - const withoutArrayBuffer = (blob: Blob): Blob => { - Object.defineProperty(blob, 'arrayBuffer', { - configurable: true, - value: undefined, - }); - return blob; + const withoutPlatformArrayBuffer = async ( + action: () => Promise, + ): Promise => { + const descriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + if (descriptor === undefined) { + await action(); + return; + } + if (!Reflect.deleteProperty(Blob.prototype, 'arrayBuffer')) { + throw new Error('Blob.prototype.arrayBuffer is not configurable.'); + } + try { + await action(); + } finally { + Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + } }; - it('uses Blob.arrayBuffer when the blob implements it', async () => { + it('uses the platform Blob.arrayBuffer capability when available', async () => { const blob = new Blob([BYTES], { type: 'application/octet-stream' }); - Object.defineProperty(blob, 'arrayBuffer', { - configurable: true, - value: () => Promise.resolve(BYTES.buffer.slice(0)), - }); const uri = await blobToDataUri(blob); - expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + expect(uri).toBe('data:application/octet-stream;base64,AQIDBA=='); }); it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { @@ -64,10 +73,10 @@ describe('readBlobBytes environment fallbacks', () => { } } vi.stubGlobal('FileReader', FailingReader); - // Shadow `arrayBuffer` on a genuine Blob so only the environment capability - // changes; the runtime Blob-brand contract remains realistic. - const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); - await expect(blobToDataUri(blob)).rejects.toThrow('reader boom'); + await withoutPlatformArrayBuffer(async () => { + const blob = new Blob([PNG], { type: 'image/png' }); + await expect(blobToDataUri(blob)).rejects.toThrow('reader boom'); + }); }); it('rejects with a synthesized error when FileReader has no error object', async () => { @@ -81,10 +90,12 @@ describe('readBlobBytes environment fallbacks', () => { } } vi.stubGlobal('FileReader', NullErrorReader); - const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); - await expect(blobToDataUri(blob)).rejects.toThrow( - /FileReader failed to read Blob/, - ); + await withoutPlatformArrayBuffer(async () => { + const blob = new Blob([PNG], { type: 'image/png' }); + await expect(blobToDataUri(blob)).rejects.toThrow( + /FileReader failed to read Blob/, + ); + }); }); it('reads through Response when neither arrayBuffer nor FileReader exist', async () => { @@ -98,8 +109,10 @@ describe('readBlobBytes environment fallbacks', () => { } }, ); - const blob = withoutArrayBuffer(new Blob([PNG], { type: 'image/png' })); - const uri = await blobToDataUri(blob); - expect(uri.startsWith('data:image/png;base64,')).toBe(true); + await withoutPlatformArrayBuffer(async () => { + const blob = new Blob([PNG], { type: 'image/png' }); + const uri = await blobToDataUri(blob); + expect(uri.startsWith('data:image/png;base64,')).toBe(true); + }); }); -}); +}); \ No newline at end of file From dbc7b966cafc6682c75fc8bf0108b7c31820eab0 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:33:26 +0900 Subject: [PATCH 53/98] fix(converter): use intrinsic Blob byte reader --- src/converter/base64.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c9946aed..640af65d 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -482,13 +482,21 @@ export function bytesToDataUri( export const arrayBufferToDataUri = bytesToDataUri; /** - * Read a Blob's bytes across environments. Prefers the standard - * `Blob.arrayBuffer()`, falling back to `FileReader` (jsdom / older DOMs that - * do not implement `arrayBuffer`) and finally to the `Response` wrapper. + * Read a Blob's bytes across environments without consulting caller-owned + * instance members. Prefer the platform `Blob.prototype.arrayBuffer` method, + * falling back to `FileReader` and then `Response` when that platform + * capability is unavailable. */ async function readBlobBytes(blob: Blob): Promise { - if (typeof blob.arrayBuffer === 'function') { - return new Uint8Array(await blob.arrayBuffer()); + const platformArrayBuffer = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + )?.value as unknown; + if (typeof platformArrayBuffer === 'function') { + const buffer = await (platformArrayBuffer as ( + this: Blob, + ) => Promise).call(blob); + return new Uint8Array(buffer); } if (typeof FileReader !== 'undefined') { return new Promise((resolve, reject) => { From d6c08be954e280e6db3e6b20ca2935e558702f15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:35:58 +0900 Subject: [PATCH 54/98] test(converter): cover intrinsic Blob byte reader --- src/converter/base64.fallbacks.test.ts | 46 +++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 4ec0c54a..512d0b71 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -50,10 +50,48 @@ describe('readBlobBytes environment fallbacks', () => { } }; - it('uses the platform Blob.arrayBuffer capability when available', async () => { - const blob = new Blob([BYTES], { type: 'application/octet-stream' }); - const uri = await blobToDataUri(blob); - expect(uri).toBe('data:application/octet-stream;base64,AQIDBA=='); + const withPlatformArrayBuffer = async ( + action: () => Promise, + ): Promise => { + const descriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value(this: Blob): Promise { + void this; + return Promise.resolve(BYTES.buffer.slice(0)); + }, + }); + try { + await action(); + } finally { + if (descriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + } + } + }; + + it('uses the platform Blob.arrayBuffer capability without consulting the instance', async () => { + await withPlatformArrayBuffer(async () => { + let instanceReads = 0; + const blob = new Blob([BYTES], { type: 'application/octet-stream' }); + Object.defineProperty(blob, 'arrayBuffer', { + configurable: true, + get() { + instanceReads += 1; + throw new Error('caller Blob arrayBuffer getter executed'); + }, + }); + + const uri = await blobToDataUri(blob); + expect(uri).toBe('data:application/octet-stream;base64,AQIDBA=='); + expect(instanceReads).toBe(0); + }); }); it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { From 56320c13001073ad83486cc91baa179735d634f5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:43:41 +0900 Subject: [PATCH 55/98] test(converter): preflight forgiving-base64 whitespace --- .../base64DecodeResourceBoundary.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts index c5c1f86e..da2e69c8 100644 --- a/src/converter/base64DecodeResourceBoundary.test.ts +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -25,6 +25,25 @@ describe('data URI decode resource boundary', () => { expect(decoder).not.toHaveBeenCalled(); }); + it('rejects oversized forgiving-base64 whitespace before decoder allocation', () => { + const decoder = vi.spyOn(globalThis.Buffer, 'from'); + let failure: unknown; + + try { + dataUriToBytes( + `data:application/octet-stream;base64,${'A A A A '.repeat(4)}`, + { maxBytes: 4 }, + ); + } catch (error) { + failure = error; + } + + expect(failure).toBeInstanceOf(Base64SizeError); + expect((failure as Base64SizeError).bytes).toBe(12); + expect((failure as Base64SizeError).maxBytes).toBe(4); + expect(decoder).not.toHaveBeenCalled(); + }); + it('accounts for one canonical padding byte without changing accepted decode', () => { expect( dataUriToBytes('data:application/octet-stream;base64,YWI=', { From b962a38cce9a2a34c9f40e09ab705b2ac12fc476 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:54:39 +0900 Subject: [PATCH 56/98] test(converter): preserve parse precedence during base64 preflight --- .../base64DecodeResourceBoundary.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts index da2e69c8..3b19d1c8 100644 --- a/src/converter/base64DecodeResourceBoundary.test.ts +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Base64SizeError, DataUriParseError, dataUriToBytes } from './index.js'; +import { + Base64ParseError, + Base64SizeError, + DataUriParseError, + dataUriToBytes, +} from './index.js'; describe('data URI decode resource boundary', () => { afterEach(() => { @@ -44,6 +49,17 @@ describe('data URI decode resource boundary', () => { expect(decoder).not.toHaveBeenCalled(); }); + it('preserves forgiving-base64 parse errors ahead of size guards', () => { + for (const payload of ['!!!!', 'A', 'YQ=']) { + expect(() => + dataUriToBytes( + `data:application/octet-stream;base64,${payload}`, + { maxBytes: 0 }, + ), + ).toThrow(Base64ParseError); + } + }); + it('accounts for one canonical padding byte without changing accepted decode', () => { expect( dataUriToBytes('data:application/octet-stream;base64,YWI=', { From da48f8c269a62f42d8e8e29d3e78141a14e9022a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 06:55:44 +0900 Subject: [PATCH 57/98] fix(converter): preflight forgiving-base64 decoded size --- src/converter/base64.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 640af65d..11d49442 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -70,6 +70,8 @@ const DATA_URI_RE = /^data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; const CANONICAL_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const FORGIVING_BASE64_ALPHABET_RE = /^[A-Za-z0-9+/]*$/; +const FORGIVING_BASE64_RAW_RE = + /^[A-Za-z0-9+/\t\n\f\r ]*(?:=[\t\n\f\r ]*){0,2}$/; const CANONICAL_PERCENT_ENCODED_ASCII_RE = /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; @@ -447,6 +449,35 @@ function canonicalBase64DecodedByteLength(payload: string): number | undefined { return (payload.length / 4) * 3 - padding; } +/** + * Return the decoded size for valid WHATWG forgiving-base64 without first + * allocating the whitespace-stripped replacement string. + */ +function forgivingBase64DecodedByteLength(payload: string): number | undefined { + if (!FORGIVING_BASE64_RAW_RE.test(payload)) return undefined; + + let normalizedLength = 0; + let padding = 0; + for (let index = 0; index < payload.length; index += 1) { + const character = payload.charAt(index); + if (character === '=') { + normalizedLength += 1; + padding += 1; + } else if (FORGIVING_BASE64_ALPHABET_RE.test(character)) { + normalizedLength += 1; + } + } + + if ( + normalizedLength % 4 === 1 || + (padding > 0 && normalizedLength % 4 !== 0) + ) { + return undefined; + } + + return Math.floor(((normalizedLength - padding) * 3) / 4); +} + function canonicalPercentEncodedAsciiDecodedByteLength( payload: string, ): number | undefined { @@ -587,7 +618,9 @@ export function dataUriToBytes( let bytes: Uint8Array; if (isBase64) { if (maxBytes !== undefined) { - const decodedLength = canonicalBase64DecodedByteLength(payload); + const decodedLength = + canonicalBase64DecodedByteLength(payload) ?? + forgivingBase64DecodedByteLength(payload); if (decodedLength !== undefined) { assertSize(decodedLength, maxBytes); } From e3ac4d2c6d05d6102b3f821c1166ace00c81a0f3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 05:09:24 +0900 Subject: [PATCH 58/98] fix(converter): preserve protected release controls --- .github/workflows/release.yml | 12 +++- src/releaseExactCheckout.test.ts | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 src/releaseExactCheckout.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cabb6da..e1d41ef6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,9 +22,18 @@ jobs: runs-on: ubuntu-24.04 steps: - name: Check out the tagged source - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.sha }} fetch-depth: 0 + persist-credentials: false + - name: Verify exact checkout + env: + INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }} + run: | + set -euo pipefail + actual_head="$(git rev-parse HEAD)" + test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA" - name: Set up pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 - name: Set up Node.js @@ -468,7 +477,6 @@ jobs: gh release edit "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --draft=false - release_immutable="$(gh release view "$GITHUB_REF_NAME" \ --repo "$GITHUB_REPOSITORY" \ --json isImmutable \ diff --git a/src/releaseExactCheckout.test.ts b/src/releaseExactCheckout.test.ts new file mode 100644 index 00000000..bb7fd53b --- /dev/null +++ b/src/releaseExactCheckout.test.ts @@ -0,0 +1,95 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const RELEASE_CHECKOUT_ACTION = + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1'; + +/** Read one authoritative repository file as UTF-8 text. */ +function repositoryFile(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +/** Extract one top-level workflow job without allowing another job to satisfy it. */ +function workflowJob(source: string, jobName: string, nextJobName: string): string { + const start = source.indexOf(` ${jobName}:`); + const end = source.indexOf(` ${nextJobName}:`, start + 1); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +/** Extract one named workflow step so unrelated steps cannot satisfy its contract. */ +function workflowStep(source: string, stepName: string, nextStepName: string): string { + const start = source.indexOf(` - name: ${stepName}`); + const end = source.indexOf(` - name: ${nextStepName}`, start + 1); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return source.slice(start, end); +} + +describe('release artifact checkout authority', () => { + it('binds the artifact build to the repository checkout baseline and exact tag SHA before setup', () => { + const workflow = repositoryFile('.github/workflows/release.yml'); + const buildJob = workflowJob( + workflow, + 'build-release-artifacts', + 'browser-release-evidence', + ); + const checkoutStep = workflowStep( + buildJob, + 'Check out the tagged source', + 'Verify exact checkout', + ); + const verifyStep = workflowStep( + buildJob, + 'Verify exact checkout', + 'Set up pnpm', + ); + + expect(checkoutStep).toContain(`uses: ${RELEASE_CHECKOUT_ACTION}`); + expect(checkoutStep).toContain('with:'); + expect(checkoutStep).toContain('ref: ${{ github.sha }}'); + expect(checkoutStep).toContain('fetch-depth: 0'); + expect(checkoutStep).toContain('persist-credentials: false'); + expect(verifyStep).toContain('INKSPAN_EXPECTED_HEAD_SHA: ${{ github.sha }}'); + expect(verifyStep).toContain('actual_head="$(git rev-parse HEAD)"'); + expect(verifyStep).toContain( + 'test "$actual_head" = "$INKSPAN_EXPECTED_HEAD_SHA"', + ); + + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up pnpm'), + ); + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up Node.js'), + ); + expect(buildJob.indexOf('name: Verify exact checkout')).toBeLessThan( + buildJob.indexOf('name: Set up Python'), + ); + }); + + it('preserves the established prerelease tag semantics while npm publication remains gated', () => { + const workflow = repositoryFile('.github/workflows/release.yml'); + const buildJob = workflowJob( + workflow, + 'build-release-artifacts', + 'browser-release-evidence', + ); + const identityStep = workflowStep( + buildJob, + 'Verify release identity and current main tip', + 'Install JavaScript dependencies', + ); + + expect(identityStep).toContain( + "if (!/^v(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[0-9A-Za-z.-]+)?$/.test(releaseTag))", + ); + expect(identityStep).toContain('Release tag is not valid semantic version syntax'); + expect(identityStep).not.toContain('valid stable semantic version syntax'); + expect(workflow).toContain( + "github.repository == 'ContextualWisdomLab/inkspan' && !contains(github.ref_name, '-')", + ); + }); +}); From bc23459946708cbb4bb083b1e67c2c79f48a93a7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:29:27 -0700 Subject: [PATCH 59/98] test(converter): expose oversized Blob MIME metadata read --- src/converter/base64BlobTypeMetadata.test.ts | 28 +++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/converter/base64BlobTypeMetadata.test.ts b/src/converter/base64BlobTypeMetadata.test.ts index 53d6f598..a9592ab5 100644 --- a/src/converter/base64BlobTypeMetadata.test.ts +++ b/src/converter/base64BlobTypeMetadata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { blobToDataUri } from './base64.js'; describe('blobToDataUri platform MIME metadata authority', () => { @@ -22,4 +22,30 @@ describe('blobToDataUri platform MIME metadata authority', () => { expect(typeAccessorRead).toBe(false); expect(uri).toBe('data:application/octet-stream;base64,AQID'); }); + + it('rejects oversized platform MIME metadata before reading Blob bytes', async () => { + const oversizedType = 'a'.repeat(1_025); + const blob = new Blob([], { type: oversizedType }); + const arrayBufferRead = vi.spyOn(Blob.prototype, 'arrayBuffer'); + + try { + await expect(blobToDataUri(blob)).rejects.toThrow( + new RangeError( + 'Blob MIME type must not exceed 1024 UTF-16 code units.', + ), + ); + expect(arrayBufferRead).not.toHaveBeenCalled(); + } finally { + arrayBufferRead.mockRestore(); + } + }); + + it('preserves a platform MIME type at the local resource ceiling', async () => { + const exactBoundaryType = 'a'.repeat(1_024); + const blob = new Blob([], { type: exactBoundaryType }); + + await expect(blobToDataUri(blob)).resolves.toBe( + `data:${exactBoundaryType};base64,`, + ); + }); }); From 617a83a33c897ad10c84cc9c229302a452b7b08b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:33:20 -0700 Subject: [PATCH 60/98] test(converter): bind Blob MIME RED to platform byte reader --- src/converter/base64BlobTypeMetadata.test.ts | 29 +++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/converter/base64BlobTypeMetadata.test.ts b/src/converter/base64BlobTypeMetadata.test.ts index a9592ab5..7ee4ef04 100644 --- a/src/converter/base64BlobTypeMetadata.test.ts +++ b/src/converter/base64BlobTypeMetadata.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { blobToDataUri } from './base64.js'; describe('blobToDataUri platform MIME metadata authority', () => { @@ -26,7 +26,20 @@ describe('blobToDataUri platform MIME metadata authority', () => { it('rejects oversized platform MIME metadata before reading Blob bytes', async () => { const oversizedType = 'a'.repeat(1_025); const blob = new Blob([], { type: oversizedType }); - const arrayBufferRead = vi.spyOn(Blob.prototype, 'arrayBuffer'); + const platformArrayBufferDescriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + let payloadReadCount = 0; + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value(): Promise { + payloadReadCount += 1; + return Promise.reject(new Error('Blob payload read sentinel.')); + }, + }); try { await expect(blobToDataUri(blob)).rejects.toThrow( @@ -34,9 +47,17 @@ describe('blobToDataUri platform MIME metadata authority', () => { 'Blob MIME type must not exceed 1024 UTF-16 code units.', ), ); - expect(arrayBufferRead).not.toHaveBeenCalled(); + expect(payloadReadCount).toBe(0); } finally { - arrayBufferRead.mockRestore(); + if (platformArrayBufferDescriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + platformArrayBufferDescriptor, + ); + } } }); From d98ef75a6f821dee45e4a1c7029268312abf902e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:39:45 -0700 Subject: [PATCH 61/98] fix(converter): preflight platform Blob MIME metadata --- src/converter/base64.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 11d49442..aa1fe6ed 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -262,9 +262,15 @@ function readBlobSize(input: unknown): number { } } -/** Return platform Blob MIME metadata without invoking caller overrides. */ +/** Return bounded platform Blob MIME metadata without invoking caller overrides. */ function readBlobType(input: Blob): string { - return BLOB_TYPE_GETTER.call(input) as string; + const mimeType = BLOB_TYPE_GETTER.call(input) as string; + if (mimeType.length > MAX_MIME_TYPE_CODE_UNITS) { + throw new RangeError( + 'Blob MIME type must not exceed 1024 UTF-16 code units.', + ); + } + return mimeType; } /** Convert only declared binary inputs to a `Uint8Array` without coercion. */ @@ -554,9 +560,9 @@ export async function blobToDataUri( ): Promise { const { mimeType, maxBytes } = resolveEncodeOptions(options); assertSize(readBlobSize(blob), maxBytes); + const blobType = readBlobType(blob); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, maxBytes); - const blobType = readBlobType(blob); const mime = mimeType || (blobType.length > 0 ? blobType : undefined) || @@ -666,4 +672,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From 1c1387d73b68bf2f8509e3a19891a70c6c991f39 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:42:14 -0700 Subject: [PATCH 62/98] test(converter): preserve explicit MIME precedence --- src/converter/base64BlobTypeMetadata.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/converter/base64BlobTypeMetadata.test.ts b/src/converter/base64BlobTypeMetadata.test.ts index 7ee4ef04..d22cc2ee 100644 --- a/src/converter/base64BlobTypeMetadata.test.ts +++ b/src/converter/base64BlobTypeMetadata.test.ts @@ -61,6 +61,15 @@ describe('blobToDataUri platform MIME metadata authority', () => { } }); + it('preserves an explicit MIME override without consulting irrelevant oversized Blob metadata', async () => { + const oversizedType = 'a'.repeat(1_025); + const blob = new Blob([], { type: oversizedType }); + + await expect( + blobToDataUri(blob, { mimeType: 'image/png' }), + ).resolves.toBe('data:image/png;base64,'); + }); + it('preserves a platform MIME type at the local resource ceiling', async () => { const exactBoundaryType = 'a'.repeat(1_024); const blob = new Blob([], { type: exactBoundaryType }); From f3dfa92b4c9a0ad889f27797a112a8e81c716f11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 17 Aug 2026 22:49:13 -0700 Subject: [PATCH 63/98] fix(converter): preserve explicit MIME override precedence --- src/converter/base64.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index aa1fe6ed..2a9b869f 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -560,7 +560,8 @@ export async function blobToDataUri( ): Promise { const { mimeType, maxBytes } = resolveEncodeOptions(options); assertSize(readBlobSize(blob), maxBytes); - const blobType = readBlobType(blob); + const blobType = + mimeType !== undefined && mimeType.length > 0 ? '' : readBlobType(blob); const bytes = await readBlobBytes(blob); assertSize(bytes.byteLength, maxBytes); const mime = From 8c3703001fb612868afe708a752fecba4f5cfe95 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:07:39 -0700 Subject: [PATCH 64/98] test(converter): preserve data URI payload whitespace --- .../base64DataUriPayloadFidelity.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/converter/base64DataUriPayloadFidelity.test.ts diff --git a/src/converter/base64DataUriPayloadFidelity.test.ts b/src/converter/base64DataUriPayloadFidelity.test.ts new file mode 100644 index 00000000..b35c37fd --- /dev/null +++ b/src/converter/base64DataUriPayloadFidelity.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { dataUriToBytes, isDataUri, parseDataUri } from './index.js'; + +describe('data URI payload fidelity', () => { + it('preserves trailing payload whitespace while retaining leading-input compatibility', () => { + const dataUri = ' \tdata:text/plain,hello%20 '; + + const parsed = parseDataUri(dataUri); + expect(parsed).toEqual({ + mimeType: 'text/plain', + isBase64: false, + payload: 'hello%20 ', + }); + expect(isDataUri(dataUri)).toBe(true); + expect(new TextDecoder().decode(dataUriToBytes(dataUri).bytes)).toBe( + 'hello ', + ); + }); + + it('preserves trailing forgiving-base64 whitespace for structural inspection', () => { + const dataUri = 'data:text/plain;base64,aGVsbG8=\n'; + + expect(parseDataUri(dataUri).payload).toBe('aGVsbG8=\n'); + expect(new TextDecoder().decode(dataUriToBytes(dataUri).bytes)).toBe('hello'); + }); +}); From 5ef7e168874ccda0f4dfffae2ed95731d2226119 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:14:02 -0700 Subject: [PATCH 65/98] fix(converter): preserve data URI payload whitespace --- src/converter/base64.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 2a9b869f..677e5409 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -588,7 +588,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { if (typeof dataUri !== 'string') { throw new DataUriParseError('String is not a valid data URI.'); } - const match = DATA_URI_RE.exec(dataUri.trim()); + const match = DATA_URI_RE.exec(dataUri.trimStart()); if (!match) { throw new DataUriParseError('String is not a valid data URI.'); } @@ -603,7 +603,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { /** `true` when the string is a syntactically valid data URI. */ export function isDataUri(value: string): boolean { - return typeof value === 'string' && DATA_URI_RE.test(value.trim()); + return typeof value === 'string' && DATA_URI_RE.test(value.trimStart()); } export interface DecodedDataUri { From dec672790c31a2e56ffa7f80387fbba1286a8b31 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:38:23 -0700 Subject: [PATCH 66/98] test(converter): distinguish RFC 2397 base64 flag --- src/converter/base64DataUriBase64Flag.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/converter/base64DataUriBase64Flag.test.ts diff --git a/src/converter/base64DataUriBase64Flag.test.ts b/src/converter/base64DataUriBase64Flag.test.ts new file mode 100644 index 00000000..72ed3712 --- /dev/null +++ b/src/converter/base64DataUriBase64Flag.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { dataUriToBytes, parseDataUri } from './index.js'; + +describe('RFC 2397 data-URI base64 flag', () => { + it('does not treat a base64 media-type parameter as the encoding flag', () => { + expect(parseDataUri('data:text/plain;base64=1,SGVsbG8%3D')).toEqual({ + mimeType: 'text/plain', + isBase64: false, + payload: 'SGVsbG8%3D', + }); + + expect( + new TextDecoder().decode( + dataUriToBytes('data:text/plain;base64=1,SGVsbG8%3D').bytes, + ), + ).toBe('SGVsbG8='); + }); + + it('still recognizes an exact final base64 flag after media-type parameters', () => { + expect( + parseDataUri('data:text/plain;charset=utf-8;base64,SGVsbG8=').isBase64, + ).toBe(true); + }); +}); From 099c159cc3eb419bf73c935d037ae8e22e5a60e8 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:39:41 -0700 Subject: [PATCH 67/98] fix(converter): require exact RFC 2397 base64 flag --- src/converter/base64.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 677e5409..0eb5d53c 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -594,7 +594,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { } const mimeType = match[1] && match[1].length > 0 ? match[1] : 'text/plain'; const params = match[2] ?? ''; - const isBase64 = /;base64/i.test(params); + const isBase64 = /;base64$/i.test(params); // Capture group 3 always matches (possibly empty), so `?? ''` is defensive. /* v8 ignore next */ const payload = match[3] ?? ''; From 5921cfa0c20baac88fb074a42e21a56303d13945 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:44:23 -0700 Subject: [PATCH 68/98] test(converter): preserve percent-encoded octets --- .../base64DataUriPercentOctets.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/converter/base64DataUriPercentOctets.test.ts diff --git a/src/converter/base64DataUriPercentOctets.test.ts b/src/converter/base64DataUriPercentOctets.test.ts new file mode 100644 index 00000000..6feaf762 --- /dev/null +++ b/src/converter/base64DataUriPercentOctets.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { dataUriToBytes } from './index.js'; + +describe('RFC 2397 percent-encoded octets', () => { + it('decodes percent escapes as raw bytes rather than UTF-8 text escapes', () => { + expect( + Array.from( + dataUriToBytes( + 'data:application/octet-stream,%00%7F%80%FF', + ).bytes, + ), + ).toEqual([0x00, 0x7f, 0x80, 0xff]); + }); + + it('keeps ordinary Unicode text encoded as UTF-8 beside escaped octets', () => { + expect( + Array.from(dataUriToBytes('data:text/plain,é%FF').bytes), + ).toEqual([0xc3, 0xa9, 0xff]); + }); +}); From 8ceb3cf0cc7768b840abac0b517160ad907e4b0b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:47:57 -0700 Subject: [PATCH 69/98] fix(converter): decode RFC 2397 percent escapes as octets --- src/converter/base64.ts | 113 ++++++++++++++++++++++++++++++---------- 1 file changed, 86 insertions(+), 27 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 0eb5d53c..1f2b62b9 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -72,8 +72,7 @@ const CANONICAL_BASE64_RE = const FORGIVING_BASE64_ALPHABET_RE = /^[A-Za-z0-9+/]*$/; const FORGIVING_BASE64_RAW_RE = /^[A-Za-z0-9+/\t\n\f\r ]*(?:=[\t\n\f\r ]*){0,2}$/; -const CANONICAL_PERCENT_ENCODED_ASCII_RE = - /^(?:[\x00-\x24\x26-\x7f]|%[0-7][0-9a-f])*$/i; +const HEX_BYTE_RE = /^[0-9a-f]{2}$/i; const INVALID_OPTIONS_MESSAGE = 'converter options are invalid.'; const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; const INVALID_BLOB_INPUT_MESSAGE = 'converter Blob input is invalid.'; @@ -484,18 +483,94 @@ function forgivingBase64DecodedByteLength(payload: string): number | undefined { return Math.floor(((normalizedLength - padding) * 3) / 4); } -function canonicalPercentEncodedAsciiDecodedByteLength( +/** Return the UTF-8 byte width used for one Unicode scalar value. */ +function utf8ByteLength(codePoint: number): number { + if (codePoint <= 0x7f) return 1; + if (codePoint <= 0x7ff) return 2; + if (codePoint <= 0xffff) return 3; + return 4; +} + +/** Normalize lone UTF-16 surrogates the same way as `TextEncoder`. */ +function scalarValue(codePoint: number): number { + return codePoint >= 0xd800 && codePoint <= 0xdfff ? 0xfffd : codePoint; +} + +/** + * Validate percent escapes and compute exact decoded bytes without allocating + * the decoded payload. RFC 2397 percent escapes represent octets directly; + * unescaped Unicode text is encoded as UTF-8 for the public string API. + */ +function percentEncodedDataUriByteLength(payload: string): number { + let byteLength = 0; + for (let index = 0; index < payload.length; index += 1) { + if (payload.charCodeAt(index) === 0x25) { + const encodedByte = payload.slice(index + 1, index + 3); + if (!HEX_BYTE_RE.test(encodedByte)) { + throw new DataUriParseError( + 'Data URI payload has malformed percent-encoding.', + ); + } + byteLength += 1; + index += 2; + continue; + } + + const codePoint = payload.codePointAt(index)!; + if (codePoint > 0xffff) index += 1; + byteLength += utf8ByteLength(scalarValue(codePoint)); + } + return byteLength; +} + +/** Write one Unicode scalar value as UTF-8 and return the next output offset. */ +function writeUtf8CodePoint( + output: Uint8Array, + offset: number, + codePoint: number, +): number { + if (codePoint <= 0x7f) { + output[offset] = codePoint; + return offset + 1; + } + if (codePoint <= 0x7ff) { + output[offset] = 0xc0 | (codePoint >> 6); + output[offset + 1] = 0x80 | (codePoint & 0x3f); + return offset + 2; + } + if (codePoint <= 0xffff) { + output[offset] = 0xe0 | (codePoint >> 12); + output[offset + 1] = 0x80 | ((codePoint >> 6) & 0x3f); + output[offset + 2] = 0x80 | (codePoint & 0x3f); + return offset + 3; + } + output[offset] = 0xf0 | (codePoint >> 18); + output[offset + 1] = 0x80 | ((codePoint >> 12) & 0x3f); + output[offset + 2] = 0x80 | ((codePoint >> 6) & 0x3f); + output[offset + 3] = 0x80 | (codePoint & 0x3f); + return offset + 4; +} + +/** Decode a validated non-base64 data-URI payload into exact octets. */ +function percentEncodedDataUriToBytes( payload: string, -): number | undefined { - if (!CANONICAL_PERCENT_ENCODED_ASCII_RE.test(payload)) return undefined; - let escapeCount = 0; + byteLength: number, +): Uint8Array { + const output = new Uint8Array(byteLength); + let offset = 0; for (let index = 0; index < payload.length; index += 1) { if (payload.charCodeAt(index) === 0x25) { - escapeCount += 1; + output[offset] = Number.parseInt(payload.slice(index + 1, index + 3), 16); + offset += 1; index += 2; + continue; } + + const codePoint = payload.codePointAt(index)!; + if (codePoint > 0xffff) index += 1; + offset = writeUtf8CodePoint(output, offset, scalarValue(codePoint)); } - return payload.length - escapeCount * 2; + return output; } /** @@ -634,25 +709,9 @@ export function dataUriToBytes( } bytes = base64ToBytes(payload); } else { - if (maxBytes !== undefined) { - const decodedLength = canonicalPercentEncodedAsciiDecodedByteLength(payload); - if (decodedLength !== undefined) { - assertSize(decodedLength, maxBytes); - } - } - // Non-base64 data URIs carry percent-encoded text. `decodeURIComponent` - // throws a raw `URIError` on malformed escapes (e.g. `%`, `%ZZ`); surface - // the module's documented `DataUriParseError` instead so callers guarding - // the parse contract handle adversarial input rather than crash. - let decoded: string; - try { - decoded = decodeURIComponent(payload); - } catch { - throw new DataUriParseError( - 'Data URI payload has malformed percent-encoding.', - ); - } - bytes = new TextEncoder().encode(decoded); + const decodedLength = percentEncodedDataUriByteLength(payload); + assertSize(decodedLength, maxBytes); + bytes = percentEncodedDataUriToBytes(payload, decodedLength); } assertSize(bytes.byteLength, maxBytes); return { mimeType, bytes }; From af7a785cd159e6036a57237cca8228e0b7fd9d8c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:48:17 -0700 Subject: [PATCH 70/98] test(converter): cover percent-octet UTF-8 boundaries --- .../base64DataUriPercentOctets.test.ts | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/converter/base64DataUriPercentOctets.test.ts b/src/converter/base64DataUriPercentOctets.test.ts index 6feaf762..eb082e65 100644 --- a/src/converter/base64DataUriPercentOctets.test.ts +++ b/src/converter/base64DataUriPercentOctets.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { dataUriToBytes } from './index.js'; +import { Base64SizeError, dataUriToBytes } from './index.js'; describe('RFC 2397 percent-encoded octets', () => { it('decodes percent escapes as raw bytes rather than UTF-8 text escapes', () => { @@ -12,9 +12,19 @@ describe('RFC 2397 percent-encoded octets', () => { ).toEqual([0x00, 0x7f, 0x80, 0xff]); }); - it('keeps ordinary Unicode text encoded as UTF-8 beside escaped octets', () => { - expect( - Array.from(dataUriToBytes('data:text/plain,é%FF').bytes), - ).toEqual([0xc3, 0xa9, 0xff]); + it('keeps every UTF-8 width and lone-surrogate replacement beside octets', () => { + const text = 'Aé€😀\uD800'; + expect(Array.from(dataUriToBytes(`data:text/plain,${text}%FF`).bytes)).toEqual([ + ...new TextEncoder().encode(text), + 0xff, + ]); + }); + + it('preflights exact percent-decoded size before materializing output bytes', () => { + expect(() => + dataUriToBytes('data:application/octet-stream,%00%7F%80%FF', { + maxBytes: 3, + }), + ).toThrow(Base64SizeError); }); }); From 5129f6de88f27fac48ce00ddbc325b0d64ada4a6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:52:53 -0700 Subject: [PATCH 71/98] test(converter): avoid leading-whitespace URI copies --- ...DataUriLeadingWhitespaceAllocation.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/converter/base64DataUriLeadingWhitespaceAllocation.test.ts diff --git a/src/converter/base64DataUriLeadingWhitespaceAllocation.test.ts b/src/converter/base64DataUriLeadingWhitespaceAllocation.test.ts new file mode 100644 index 00000000..df2aba93 --- /dev/null +++ b/src/converter/base64DataUriLeadingWhitespaceAllocation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { isDataUri, parseDataUri } from './index.js'; + +describe('data-URI leading-whitespace preflight', () => { + it('accepts compatibility whitespace without allocating through trimStart', () => { + const originalTrimStart = String.prototype.trimStart; + let parsed: ReturnType | undefined; + let recognized = false; + + Object.defineProperty(String.prototype, 'trimStart', { + configurable: true, + writable: true, + value() { + throw new Error('trimStart must not materialize the caller-controlled URI'); + }, + }); + + try { + parsed = parseDataUri('\uFEFF \tdata:text/plain,hello '); + recognized = isDataUri('\uFEFF \tdata:text/plain,hello '); + } finally { + Object.defineProperty(String.prototype, 'trimStart', { + configurable: true, + writable: true, + value: originalTrimStart, + }); + } + + expect(parsed).toEqual({ + mimeType: 'text/plain', + isBase64: false, + payload: 'hello ', + }); + expect(recognized).toBe(true); + }); +}); From e1506fc7c938325bf822e4c8ad0da85c24841531 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 00:54:52 -0700 Subject: [PATCH 72/98] fix(converter): parse leading URI whitespace without copying --- src/converter/base64.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 1f2b62b9..4ba41b1c 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -66,7 +66,7 @@ export interface ParsedDataUri { payload: string; } -const DATA_URI_RE = /^data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; +const DATA_URI_RE = /^\s*data:([^;,]*)?((?:;[^;,]+)*)?,([\s\S]*)$/; const CANONICAL_BASE64_RE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; const FORGIVING_BASE64_ALPHABET_RE = /^[A-Za-z0-9+/]*$/; @@ -663,7 +663,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { if (typeof dataUri !== 'string') { throw new DataUriParseError('String is not a valid data URI.'); } - const match = DATA_URI_RE.exec(dataUri.trimStart()); + const match = DATA_URI_RE.exec(dataUri); if (!match) { throw new DataUriParseError('String is not a valid data URI.'); } @@ -678,7 +678,7 @@ export function parseDataUri(dataUri: string): ParsedDataUri { /** `true` when the string is a syntactically valid data URI. */ export function isDataUri(value: string): boolean { - return typeof value === 'string' && DATA_URI_RE.test(value.trimStart()); + return typeof value === 'string' && DATA_URI_RE.test(value); } export interface DecodedDataUri { From 5b4d63531eed1cb69d57a7942bd8f5f20c4b455e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:09:54 -0700 Subject: [PATCH 73/98] test(converter): reject prototype-chain classification authority --- src/converter/base64ViewRuntimeInput.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts index 9415eef2..2435b26a 100644 --- a/src/converter/base64ViewRuntimeInput.test.ts +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -54,6 +54,21 @@ describe('converter binary-view runtime boundary', () => { expect(readCallerAccessor).not.toHaveBeenCalled(); }); + it('does not traverse a caller-controlled prototype while classifying a genuine Uint8Array', () => { + const view = new Uint8Array([1, 2, 3]); + const privateSentinel = new Error('private typed-array prototype sentinel'); + const getPrototypeOf = vi.fn((): never => { + throw privateSentinel; + }); + const hostilePrototype = new Proxy(Uint8Array.prototype, { + getPrototypeOf, + }); + Object.setPrototypeOf(view, hostilePrototype); + + expect(toUint8Array(view)).toBe(view); + expect(getPrototypeOf).not.toHaveBeenCalled(); + }); + it('normalizes detached DataView failures before range reconstruction', () => { const buffer = new ArrayBuffer(4); const view = new DataView(buffer, 1, 2); From 7d197ee87edd08666a3df06b894c728f22f972ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 02:20:50 -0700 Subject: [PATCH 74/98] fix(converter): classify Uint8Array by intrinsic slots --- src/converter/base64.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 4ba41b1c..c8f0a565 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -277,9 +277,9 @@ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { if (ArrayBuffer.isView(input)) { - if (input instanceof Uint8Array) { + if (TYPED_ARRAY_TAG_GETTER.call(input) === 'Uint8Array') { readUint8ArraySlots(input); - return input; + return input as Uint8Array; } const { buffer, byteOffset, byteLength } = readArrayBufferViewSlots(input); return new Uint8Array(buffer, byteOffset, byteLength); From 1afb16b9822e24a7151d5b619a90cbde951ae3d6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:09:40 -0700 Subject: [PATCH 75/98] test(converter): reject mutated Blob byte-read authority --- src/converter/base64.fallbacks.test.ts | 27 +++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 512d0b71..c60a0ee1 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -94,6 +94,31 @@ describe('readBlobBytes environment fallbacks', () => { }); }); + it('does not let a later Blob.prototype.arrayBuffer replacement become payload authority', async () => { + const descriptor = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', + ); + if (typeof descriptor?.value !== 'function') { + throw new Error('Blob.prototype.arrayBuffer is required by this regression.'); + } + + const blob = new Blob([BYTES], { type: 'application/octet-stream' }); + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value(): Promise { + return Promise.resolve(new Uint8Array([9, 9, 9, 9]).buffer); + }, + }); + try { + const uri = await blobToDataUri(blob); + expect(uri).toBe('data:application/octet-stream;base64,AQIDBA=='); + } finally { + Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + } + }); + it('falls back to application/octet-stream for a typeless, unsniffable blob', async () => { const blob = new Blob([new Uint8Array([0, 1, 2, 3])]); const uri = await blobToDataUri(blob); @@ -153,4 +178,4 @@ describe('readBlobBytes environment fallbacks', () => { expect(uri.startsWith('data:image/png;base64,')).toBe(true); }); }); -}); \ No newline at end of file +}); From 30d2d34782258220dcf29cf9f88bbb26600656fa Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:12:50 -0700 Subject: [PATCH 76/98] fix(converter): pin Blob byte-read capability --- src/converter/base64.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c8f0a565..84d3d78c 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -119,6 +119,10 @@ const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'type', )!.get!; +const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', +)?.value as unknown; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -595,17 +599,20 @@ export const arrayBufferToDataUri = bytesToDataUri; /** * Read a Blob's bytes across environments without consulting caller-owned - * instance members. Prefer the platform `Blob.prototype.arrayBuffer` method, - * falling back to `FileReader` and then `Response` when that platform - * capability is unavailable. + * instance members. Prefer the module-captured `Blob.prototype.arrayBuffer` + * capability when that platform method is still present, falling back to + * `FileReader` and then `Response` when the platform capability is unavailable. */ async function readBlobBytes(blob: Blob): Promise { - const platformArrayBuffer = Object.getOwnPropertyDescriptor( + const currentPlatformArrayBuffer = Object.getOwnPropertyDescriptor( Blob.prototype, 'arrayBuffer', )?.value as unknown; - if (typeof platformArrayBuffer === 'function') { - const buffer = await (platformArrayBuffer as ( + if ( + typeof BLOB_ARRAY_BUFFER_METHOD === 'function' && + typeof currentPlatformArrayBuffer === 'function' + ) { + const buffer = await (BLOB_ARRAY_BUFFER_METHOD as ( this: Blob, ) => Promise).call(blob); return new Uint8Array(buffer); @@ -732,4 +739,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From 6c5f83897c02cb509bdd7b2b0b18404bcd1ad34f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:14:05 -0700 Subject: [PATCH 77/98] test(converter): make Blob authority regression environment-realistic --- src/converter/base64.fallbacks.test.ts | 32 ++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index c60a0ee1..5d9ca467 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -99,23 +99,37 @@ describe('readBlobBytes environment fallbacks', () => { Blob.prototype, 'arrayBuffer', ); - if (typeof descriptor?.value !== 'function') { - throw new Error('Blob.prototype.arrayBuffer is required by this regression.'); - } - - const blob = new Blob([BYTES], { type: 'application/octet-stream' }); Object.defineProperty(Blob.prototype, 'arrayBuffer', { configurable: true, writable: true, - value(): Promise { - return Promise.resolve(new Uint8Array([9, 9, 9, 9]).buffer); + value(this: Blob): Promise { + void this; + return Promise.resolve(BYTES.buffer.slice(0)); }, }); + try { - const uri = await blobToDataUri(blob); + vi.resetModules(); + const { blobToDataUri: isolatedBlobToDataUri } = await import('./base64.js'); + const blob = new Blob([BYTES], { type: 'application/octet-stream' }); + + Object.defineProperty(Blob.prototype, 'arrayBuffer', { + configurable: true, + writable: true, + value(): Promise { + return Promise.resolve(new Uint8Array([9, 9, 9, 9]).buffer); + }, + }); + + const uri = await isolatedBlobToDataUri(blob); expect(uri).toBe('data:application/octet-stream;base64,AQIDBA=='); } finally { - Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + if (descriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + } + vi.resetModules(); } }); From 7803940480bf6ea69d7bbea4b593ace7c9708ee6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 03:16:05 -0700 Subject: [PATCH 78/98] test(converter): prove mutable Blob read authority fails closed --- src/converter/base64.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 84d3d78c..c8f0a565 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -119,10 +119,6 @@ const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'type', )!.get!; -const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( - Blob.prototype, - 'arrayBuffer', -)?.value as unknown; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -599,20 +595,17 @@ export const arrayBufferToDataUri = bytesToDataUri; /** * Read a Blob's bytes across environments without consulting caller-owned - * instance members. Prefer the module-captured `Blob.prototype.arrayBuffer` - * capability when that platform method is still present, falling back to - * `FileReader` and then `Response` when the platform capability is unavailable. + * instance members. Prefer the platform `Blob.prototype.arrayBuffer` method, + * falling back to `FileReader` and then `Response` when that platform + * capability is unavailable. */ async function readBlobBytes(blob: Blob): Promise { - const currentPlatformArrayBuffer = Object.getOwnPropertyDescriptor( + const platformArrayBuffer = Object.getOwnPropertyDescriptor( Blob.prototype, 'arrayBuffer', )?.value as unknown; - if ( - typeof BLOB_ARRAY_BUFFER_METHOD === 'function' && - typeof currentPlatformArrayBuffer === 'function' - ) { - const buffer = await (BLOB_ARRAY_BUFFER_METHOD as ( + if (typeof platformArrayBuffer === 'function') { + const buffer = await (platformArrayBuffer as ( this: Blob, ) => Promise).call(blob); return new Uint8Array(buffer); @@ -739,4 +732,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} +} \ No newline at end of file From 754c5d87dcf3a7f18ca20b15877cb308be4924d4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 04:00:09 -0700 Subject: [PATCH 79/98] fix(converter): pin Blob read capability --- src/converter/base64.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c8f0a565..f911de1b 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -119,6 +119,10 @@ const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( Blob.prototype, 'type', )!.get!; +const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', +)?.value as unknown; const hasBuffer = typeof globalThis.Buffer !== 'undefined'; @@ -595,17 +599,15 @@ export const arrayBufferToDataUri = bytesToDataUri; /** * Read a Blob's bytes across environments without consulting caller-owned - * instance members. Prefer the platform `Blob.prototype.arrayBuffer` method, - * falling back to `FileReader` and then `Response` when that platform - * capability is unavailable. + * instance members. Capture the platform read capability at module evaluation + * so a later prototype replacement cannot become payload authority, while a + * missing live platform member still selects the documented fallbacks. */ async function readBlobBytes(blob: Blob): Promise { - const platformArrayBuffer = Object.getOwnPropertyDescriptor( - Blob.prototype, - 'arrayBuffer', - )?.value as unknown; - if (typeof platformArrayBuffer === 'function') { - const buffer = await (platformArrayBuffer as ( + const hasPlatformArrayBuffer = + Object.getOwnPropertyDescriptor(Blob.prototype, 'arrayBuffer') !== undefined; + if (hasPlatformArrayBuffer && typeof BLOB_ARRAY_BUFFER_METHOD === 'function') { + const buffer = await (BLOB_ARRAY_BUFFER_METHOD as ( this: Blob, ) => Promise).call(blob); return new Uint8Array(buffer); From bdb0106913321ce0f09c2dbcf53f706703ab6722 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:38:03 -0700 Subject: [PATCH 80/98] test(converter): pin ArrayBuffer view authority --- src/converter/base64.fallbacks.test.ts | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 5d9ca467..d4171384 100644 --- a/src/converter/base64.fallbacks.test.ts +++ b/src/converter/base64.fallbacks.test.ts @@ -16,6 +16,29 @@ describe('toUint8Array with a non-Uint8Array view', () => { expect(out).toBeInstanceOf(Uint8Array); expect(Array.from(out)).toEqual([8, 7, 6]); }); + + it('does not let a later ArrayBuffer.isView replacement become classification authority', () => { + const descriptor = Object.getOwnPropertyDescriptor(ArrayBuffer, 'isView'); + if (descriptor === undefined) { + throw new Error('ArrayBuffer.isView descriptor is unavailable.'); + } + const privateSentinel = new Error('private ArrayBuffer.isView sentinel'); + Object.defineProperty(ArrayBuffer, 'isView', { + ...descriptor, + value(): boolean { + throw privateSentinel; + }, + }); + + try { + const src = new Uint8Array([9, 8, 7, 6, 5]); + const view = new Int8Array(src.buffer, 1, 3); + const out = toUint8Array(view); + expect(Array.from(out)).toEqual([8, 7, 6]); + } finally { + Object.defineProperty(ArrayBuffer, 'isView', descriptor); + } + }); }); // Note: the `btoa`/`atob` fallback in bytesToBase64/base64ToBytes only runs in @@ -192,4 +215,4 @@ describe('readBlobBytes environment fallbacks', () => { expect(uri.startsWith('data:image/png;base64,')).toBe(true); }); }); -}); +}); \ No newline at end of file From 429fa3c838720c38d869aa26c56dd7c20cc96a7e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 05:43:28 -0700 Subject: [PATCH 81/98] fix(converter): capture ArrayBuffer view classifier --- src/converter/base64.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index f911de1b..c6226996 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -78,6 +78,7 @@ const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; const INVALID_BLOB_INPUT_MESSAGE = 'converter Blob input is invalid.'; const INVALID_BASE64_INPUT_MESSAGE = 'base64 input must be a string.'; const MAX_MIME_TYPE_CODE_UNITS = 1_024; +const ARRAY_BUFFER_IS_VIEW = ArrayBuffer.isView; const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, 'byteLength', @@ -280,7 +281,7 @@ function readBlobType(input: Blob): string { export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { - if (ArrayBuffer.isView(input)) { + if (ARRAY_BUFFER_IS_VIEW(input)) { if (TYPED_ARRAY_TAG_GETTER.call(input) === 'Uint8Array') { readUint8ArraySlots(input); return input as Uint8Array; From 07da81a11f29514dc5f44976adbcab699a21080e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:10:19 -0700 Subject: [PATCH 82/98] test(converter): pin MIME decoder authority --- src/converter/sniffMimeRuntimeInput.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/converter/sniffMimeRuntimeInput.test.ts b/src/converter/sniffMimeRuntimeInput.test.ts index af938d59..2f6a0ea3 100644 --- a/src/converter/sniffMimeRuntimeInput.test.ts +++ b/src/converter/sniffMimeRuntimeInput.test.ts @@ -39,4 +39,21 @@ describe('MIME sniffing runtime binary boundary', () => { expect(readLength).not.toHaveBeenCalled(); expect(callSubarray).not.toHaveBeenCalled(); }); + + it('does not let a later TextDecoder replacement become MIME classification authority', () => { + const privateSentinel = new Error('private TextDecoder sentinel'); + class HostileTextDecoder { + constructor() { + throw privateSentinel; + } + } + + vi.stubGlobal('TextDecoder', HostileTextDecoder); + try { + const svg = new Uint8Array([0x3c, 0x73, 0x76, 0x67, 0x3e]); + expect(sniffMimeType(svg)).toBe('image/svg+xml'); + } finally { + vi.unstubAllGlobals(); + } + }); }); From 30d5e4f6a984f26153f4a1535f306553887cf5c5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 07:15:36 -0700 Subject: [PATCH 83/98] fix(converter): capture MIME decoder authority --- src/converter/base64.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index c6226996..d335e78f 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -78,6 +78,8 @@ const INVALID_BINARY_INPUT_MESSAGE = 'converter binary input is invalid.'; const INVALID_BLOB_INPUT_MESSAGE = 'converter Blob input is invalid.'; const INVALID_BASE64_INPUT_MESSAGE = 'base64 input must be a string.'; const MAX_MIME_TYPE_CODE_UNITS = 1_024; +const TEXT_DECODER = new TextDecoder(); +const TEXT_DECODER_DECODE = TextDecoder.prototype.decode; const ARRAY_BUFFER_IS_VIEW = ArrayBuffer.isView; const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, @@ -358,9 +360,10 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined { return 'application/pdf'; } // SVG / XML: look for " Date: Wed, 19 Aug 2026 09:33:45 -0700 Subject: [PATCH 84/98] test(converter): reject live global Buffer authority --- src/converter/base64.buffer-authority.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/converter/base64.buffer-authority.test.ts diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts new file mode 100644 index 00000000..a0e8575c --- /dev/null +++ b/src/converter/base64.buffer-authority.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { base64ToBytes, bytesToBase64 } from './base64.js'; + +describe('base64 runtime authority', () => { + it('does not let a later global Buffer replacement become codec authority', () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Buffer'); + if (descriptor === undefined) { + throw new Error('global Buffer descriptor is unavailable in the Node test runtime.'); + } + const privateSentinel = new Error('private global Buffer sentinel'); + + Object.defineProperty(globalThis, 'Buffer', { + configurable: true, + writable: true, + value: { + from(): never { + throw privateSentinel; + }, + }, + }); + + try { + expect(bytesToBase64(new Uint8Array([1, 2, 3, 4]))).toBe('AQIDBA=='); + expect(Array.from(base64ToBytes('AQIDBA=='))).toEqual([1, 2, 3, 4]); + } finally { + Object.defineProperty(globalThis, 'Buffer', descriptor); + } + }); +}); From ce661cb3a77375ed8a5c438dac334d7b64816d86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:40:48 -0700 Subject: [PATCH 85/98] fix(converter): capture Node Buffer codec authority --- src/converter/base64.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index d335e78f..31de90af 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -126,8 +126,8 @@ const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( Blob.prototype, 'arrayBuffer', )?.value as unknown; - -const hasBuffer = typeof globalThis.Buffer !== 'undefined'; +const NODE_BUFFER = globalThis.Buffer; +const hasBuffer = typeof NODE_BUFFER !== 'undefined'; interface Uint8ArraySlots { buffer: ArrayBufferLike; @@ -200,7 +200,7 @@ export function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } /* v8 ignore stop */ - return globalThis.Buffer.from(buffer, byteOffset, byteLength).toString('base64'); + return NODE_BUFFER.from(buffer, byteOffset, byteLength).toString('base64'); } /** @@ -246,7 +246,7 @@ export function base64ToBytes(base64: string): Uint8Array { return out; } /* v8 ignore stop */ - return new Uint8Array(globalThis.Buffer.from(normalized, 'base64')); + return new Uint8Array(NODE_BUFFER.from(normalized, 'base64')); } /** Return whether a value carries the platform ArrayBuffer internal slot. */ From 45ad5d37885a5d2e2613c4a77b6867ac6a0fb100 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:45:15 -0700 Subject: [PATCH 86/98] test(converter): reject mutable Buffer.from authority --- src/converter/base64.buffer-authority.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts index a0e8575c..ef5e3953 100644 --- a/src/converter/base64.buffer-authority.test.ts +++ b/src/converter/base64.buffer-authority.test.ts @@ -26,4 +26,30 @@ describe('base64 runtime authority', () => { Object.defineProperty(globalThis, 'Buffer', descriptor); } }); + + it('does not let a later Buffer.from replacement become codec authority', () => { + const buffer = globalThis.Buffer; + if (buffer === undefined) { + throw new Error('global Buffer is unavailable in the Node test runtime.'); + } + const descriptor = Object.getOwnPropertyDescriptor(buffer, 'from'); + if (descriptor === undefined) { + throw new Error('Buffer.from descriptor is unavailable in the Node test runtime.'); + } + const privateSentinel = new Error('private Buffer.from sentinel'); + + Object.defineProperty(buffer, 'from', { + ...descriptor, + value(): never { + throw privateSentinel; + }, + }); + + try { + expect(bytesToBase64(new Uint8Array([1, 2, 3, 4]))).toBe('AQIDBA=='); + expect(Array.from(base64ToBytes('AQIDBA=='))).toEqual([1, 2, 3, 4]); + } finally { + Object.defineProperty(buffer, 'from', descriptor); + } + }); }); From 8fbcd2d5df354fa34f680faf79327e22f08ca862 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 09:49:46 -0700 Subject: [PATCH 87/98] fix(converter): capture Buffer.from capability --- src/converter/base64.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 31de90af..327a553f 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -127,7 +127,11 @@ const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( 'arrayBuffer', )?.value as unknown; const NODE_BUFFER = globalThis.Buffer; -const hasBuffer = typeof NODE_BUFFER !== 'undefined'; +const NODE_BUFFER_FROM = + typeof NODE_BUFFER === 'undefined' + ? undefined + : NODE_BUFFER.from.bind(NODE_BUFFER); +const hasBuffer = typeof NODE_BUFFER_FROM === 'function'; interface Uint8ArraySlots { buffer: ArrayBufferLike; @@ -200,7 +204,7 @@ export function bytesToBase64(bytes: Uint8Array): string { return btoa(binary); } /* v8 ignore stop */ - return NODE_BUFFER.from(buffer, byteOffset, byteLength).toString('base64'); + return NODE_BUFFER_FROM!(buffer, byteOffset, byteLength).toString('base64'); } /** @@ -246,7 +250,7 @@ export function base64ToBytes(base64: string): Uint8Array { return out; } /* v8 ignore stop */ - return new Uint8Array(NODE_BUFFER.from(normalized, 'base64')); + return new Uint8Array(NODE_BUFFER_FROM!(normalized, 'base64')); } /** Return whether a value carries the platform ArrayBuffer internal slot. */ From 675572617f88b5b37337075d9253a7d2adb328cf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 19 Aug 2026 10:33:16 -0700 Subject: [PATCH 88/98] test(converter): cover Bufferless codec initialization --- src/converter/base64.buffer-authority.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts index ef5e3953..269154c4 100644 --- a/src/converter/base64.buffer-authority.test.ts +++ b/src/converter/base64.buffer-authority.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { base64ToBytes, bytesToBase64 } from './base64.js'; describe('base64 runtime authority', () => { @@ -52,4 +52,28 @@ describe('base64 runtime authority', () => { Object.defineProperty(buffer, 'from', descriptor); } }); + + it('initializes the browser codec path without a global Buffer', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Buffer'); + if (descriptor === undefined || descriptor.configurable !== true) { + throw new Error('global Buffer must be configurable in the Node test runtime.'); + } + + Object.defineProperty(globalThis, 'Buffer', { + configurable: true, + enumerable: descriptor.enumerable, + writable: true, + value: undefined, + }); + + try { + vi.resetModules(); + const browserCodec = await import('./base64.js'); + expect(typeof browserCodec.bytesToBase64).toBe('function'); + expect(typeof browserCodec.base64ToBytes).toBe('function'); + } finally { + Object.defineProperty(globalThis, 'Buffer', descriptor); + vi.resetModules(); + } + }); }); From a393fd84abb00d928178114d4f791bb86c3df29b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:29:06 +0900 Subject: [PATCH 89/98] test(converter): avoid mutating Vitest Buffer authority --- src/converter/base64.buffer-authority.test.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts index 269154c4..ef5e3953 100644 --- a/src/converter/base64.buffer-authority.test.ts +++ b/src/converter/base64.buffer-authority.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { base64ToBytes, bytesToBase64 } from './base64.js'; describe('base64 runtime authority', () => { @@ -52,28 +52,4 @@ describe('base64 runtime authority', () => { Object.defineProperty(buffer, 'from', descriptor); } }); - - it('initializes the browser codec path without a global Buffer', async () => { - const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Buffer'); - if (descriptor === undefined || descriptor.configurable !== true) { - throw new Error('global Buffer must be configurable in the Node test runtime.'); - } - - Object.defineProperty(globalThis, 'Buffer', { - configurable: true, - enumerable: descriptor.enumerable, - writable: true, - value: undefined, - }); - - try { - vi.resetModules(); - const browserCodec = await import('./base64.js'); - expect(typeof browserCodec.bytesToBase64).toBe('function'); - expect(typeof browserCodec.base64ToBytes).toBe('function'); - } finally { - Object.defineProperty(globalThis, 'Buffer', descriptor); - vi.resetModules(); - } - }); }); From 98c9d1fa319a27fbdcc210b1490d7380e35170dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 20 Aug 2026 21:43:49 +0900 Subject: [PATCH 90/98] test(converter): mark browser-only buffer initialization --- src/converter/base64.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 327a553f..431e94bb 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -127,10 +127,12 @@ const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( 'arrayBuffer', )?.value as unknown; const NODE_BUFFER = globalThis.Buffer; +/* v8 ignore start -- browser-only module initialization without Node Buffer. */ const NODE_BUFFER_FROM = typeof NODE_BUFFER === 'undefined' ? undefined : NODE_BUFFER.from.bind(NODE_BUFFER); +/* v8 ignore stop */ const hasBuffer = typeof NODE_BUFFER_FROM === 'function'; interface Uint8ArraySlots { @@ -742,4 +744,4 @@ export function dataUriToBlob( /** The decoded byte length of a data URI without materialising a Blob. */ export function dataUriByteLength(dataUri: string): number { return dataUriToBytes(dataUri).bytes.byteLength; -} \ No newline at end of file +} From 1754029885c90980c58892af37a24b789a68bdc7 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:35:42 -0700 Subject: [PATCH 91/98] test(converter): expose Buffer startup coverage gap --- src/converter/base64.buffer-authority.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts index ef5e3953..40ee637c 100644 --- a/src/converter/base64.buffer-authority.test.ts +++ b/src/converter/base64.buffer-authority.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { base64ToBytes, bytesToBase64 } from './base64.js'; +type BufferFrom = typeof globalThis.Buffer.from; + describe('base64 runtime authority', () => { it('does not let a later global Buffer replacement become codec authority', () => { const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Buffer'); @@ -52,4 +54,33 @@ describe('base64 runtime authority', () => { Object.defineProperty(buffer, 'from', descriptor); } }); + + it('covers browser startup Buffer resolution without mutating Vitest globals', async () => { + const converterModule = (await import('./base64.js')) as unknown as Record< + string, + unknown + >; + const resolver = converterModule.resolveNodeBufferFrom; + + expect(resolver).toBeTypeOf('function'); + if (typeof resolver !== 'function') { + throw new Error('Buffer authority resolver is unavailable.'); + } + + const resolveNodeBufferFrom = resolver as ( + buffer: typeof globalThis.Buffer | undefined, + ) => BufferFrom | undefined; + expect(resolveNodeBufferFrom(undefined)).toBeUndefined(); + + const buffer = globalThis.Buffer; + if (buffer === undefined) { + throw new Error('global Buffer is unavailable in the Node test runtime.'); + } + const captured = resolveNodeBufferFrom(buffer); + expect(captured).toBeTypeOf('function'); + if (captured === undefined) { + throw new Error('Node Buffer.from authority was not captured.'); + } + expect(captured('AQIDBA==', 'base64').toString('hex')).toBe('01020304'); + }); }); From 1b5d037b4c97d8fc0c77a97197e65ee59fc111be Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:40:47 -0700 Subject: [PATCH 92/98] fix(converter): cover Buffer startup authority --- src/converter/base64.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 431e94bb..e78bc821 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -126,13 +126,19 @@ const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( Blob.prototype, 'arrayBuffer', )?.value as unknown; -const NODE_BUFFER = globalThis.Buffer; -/* v8 ignore start -- browser-only module initialization without Node Buffer. */ -const NODE_BUFFER_FROM = - typeof NODE_BUFFER === 'undefined' - ? undefined - : NODE_BUFFER.from.bind(NODE_BUFFER); -/* v8 ignore stop */ + +type NodeBuffer = typeof globalThis.Buffer; +type NodeBufferFrom = NodeBuffer['from']; + +/** Capture Node's Buffer.from authority when present without mutating globals. */ +export function resolveNodeBufferFrom( + buffer: NodeBuffer | undefined, +): NodeBufferFrom | undefined { + if (buffer === undefined) return undefined; + return buffer.from.bind(buffer) as NodeBufferFrom; +} + +const NODE_BUFFER_FROM = resolveNodeBufferFrom(globalThis.Buffer); const hasBuffer = typeof NODE_BUFFER_FROM === 'function'; interface Uint8ArraySlots { From ecbf842fdf131782cd28fd72a888b1d6d96a341b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 03:46:27 -0700 Subject: [PATCH 93/98] fix(converter): keep Buffer authority declarations portable --- src/converter/base64.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index e78bc821..366b701e 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -127,18 +127,22 @@ const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( 'arrayBuffer', )?.value as unknown; -type NodeBuffer = typeof globalThis.Buffer; -type NodeBufferFrom = NodeBuffer['from']; +type BindableBufferFrom = (...args: never[]) => unknown; +interface BufferAuthority { + from: BindableBufferFrom; +} /** Capture Node's Buffer.from authority when present without mutating globals. */ export function resolveNodeBufferFrom( - buffer: NodeBuffer | undefined, -): NodeBufferFrom | undefined { + buffer: BufferAuthority | undefined, +): BindableBufferFrom | undefined { if (buffer === undefined) return undefined; - return buffer.from.bind(buffer) as NodeBufferFrom; + return buffer.from.bind(buffer); } -const NODE_BUFFER_FROM = resolveNodeBufferFrom(globalThis.Buffer); +const NODE_BUFFER_FROM = resolveNodeBufferFrom( + globalThis.Buffer as unknown as BufferAuthority | undefined, +) as typeof globalThis.Buffer.from | undefined; const hasBuffer = typeof NODE_BUFFER_FROM === 'function'; interface Uint8ArraySlots { From 5321c9dede813e88b7474b165a3ea8431fc34104 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:01:07 -0700 Subject: [PATCH 94/98] test(converter): bound data URI MIME metadata --- .../base64DataUriMimeMetadata.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/converter/base64DataUriMimeMetadata.test.ts diff --git a/src/converter/base64DataUriMimeMetadata.test.ts b/src/converter/base64DataUriMimeMetadata.test.ts new file mode 100644 index 00000000..33379e83 --- /dev/null +++ b/src/converter/base64DataUriMimeMetadata.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { + DataUriParseError, + dataUriToBytes, + parseDataUri, +} from './base64.js'; + +describe('data URI MIME metadata resource boundary', () => { + it('rejects oversized declared MIME metadata before payload decoding', () => { + const oversizedMimeType = 'a'.repeat(1_025); + + expect(() => + dataUriToBytes(`data:${oversizedMimeType},%GG`), + ).toThrow( + new DataUriParseError( + 'Data URI MIME type must not exceed 1024 UTF-16 code units.', + ), + ); + }); + + it('accepts declared MIME metadata at the local resource ceiling', () => { + const exactBoundaryMimeType = 'a'.repeat(1_024); + + expect(parseDataUri(`data:${exactBoundaryMimeType},payload`)).toEqual({ + mimeType: exactBoundaryMimeType, + isBase64: false, + payload: 'payload', + }); + }); + + it('preserves the default MIME type when the declaration is omitted', () => { + expect(parseDataUri('data:,payload')).toEqual({ + mimeType: 'text/plain', + isBase64: false, + payload: 'payload', + }); + }); +}); From 42b3a5004ec1b93181cd1ae52227b0fac2506227 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 22 Aug 2026 00:05:19 -0700 Subject: [PATCH 95/98] fix(converter): bound data URI MIME metadata --- src/converter/base64.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 366b701e..1d85c2f6 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -689,7 +689,13 @@ export function parseDataUri(dataUri: string): ParsedDataUri { if (!match) { throw new DataUriParseError('String is not a valid data URI.'); } - const mimeType = match[1] && match[1].length > 0 ? match[1] : 'text/plain'; + const declaredMimeType = match[1] ?? ''; + if (declaredMimeType.length > MAX_MIME_TYPE_CODE_UNITS) { + throw new DataUriParseError( + 'Data URI MIME type must not exceed 1024 UTF-16 code units.', + ); + } + const mimeType = declaredMimeType.length > 0 ? declaredMimeType : 'text/plain'; const params = match[2] ?? ''; const isBase64 = /;base64$/i.test(params); // Capture group 3 always matches (possibly empty), so `?? ''` is defensive. From d9982368602d472f15ebd67628c1a080cd0a5d71 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:09:56 -0700 Subject: [PATCH 96/98] test(converter): specify inclusive size guidance boundary --- src/converter/base64SizeGuidance.test.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/converter/base64SizeGuidance.test.ts diff --git a/src/converter/base64SizeGuidance.test.ts b/src/converter/base64SizeGuidance.test.ts new file mode 100644 index 00000000..cc64e5bf --- /dev/null +++ b/src/converter/base64SizeGuidance.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { Base64SizeError, bytesToDataUri } from './index.js'; + +describe('Base64 size guidance', () => { + it('states the inclusive maxBytes boundary in exact human units', () => { + expect(() => + bytesToDataUri(new Uint8Array(4), { maxBytes: 4 }), + ).not.toThrow(); + + expect(new Base64SizeError(5, 4).message).toContain( + 'at or below 4 bytes', + ); + expect(new Base64SizeError(2049, 2048).message).toContain( + 'at or below 2 KB', + ); + expect(new Base64SizeError(3 * 1024 * 1024 + 1, 3 * 1024 * 1024).message).toContain( + 'at or below 3 MB', + ); + expect(new Base64SizeError(1501, 1500).message).toContain( + 'at or below 1500 bytes', + ); + }); +}); From 0727ecba1c8e2e53a3424dafb226ffd7af77c7c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:15:50 -0700 Subject: [PATCH 97/98] fix(converter): align size guidance with inclusive limit --- src/converter/base64.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 1d85c2f6..783a8737 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -19,7 +19,7 @@ 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; @@ -27,6 +27,17 @@ export class Base64SizeError extends Error { } } +/** 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 valid forgiving-base64 data. */ export class Base64ParseError extends Error { constructor() { From dbb9c03eede99f65cee0762edebd09f2d63abc7b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 25 Aug 2026 21:23:22 -0700 Subject: [PATCH 98/98] fix(converter): preserve stable oversize signal in guidance --- src/converter/base64.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 783a8737..1ad075d0 100644 --- a/src/converter/base64.ts +++ b/src/converter/base64.ts @@ -19,7 +19,7 @@ export class Base64SizeError extends Error { readonly maxBytes: number; constructor(bytes: number, maxBytes: number) { super( - `This file is too large to insert. Choose a file at or below ${formatByteLimit(maxBytes)}.`, + `This file is too large to insert because it exceeds the size limit. Choose a file at or below ${formatByteLimit(maxBytes)}.`, ); this.name = 'Base64SizeError'; this.bytes = bytes;