diff --git a/src/converter/base64.buffer-authority.test.ts b/src/converter/base64.buffer-authority.test.ts new file mode 100644 index 00000000..40ee637c --- /dev/null +++ b/src/converter/base64.buffer-authority.test.ts @@ -0,0 +1,86 @@ +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'); + 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); + } + }); + + 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); + } + }); + + 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'); + }); +}); diff --git a/src/converter/base64.fallbacks.test.ts b/src/converter/base64.fallbacks.test.ts index 13586cb2..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 @@ -29,21 +52,113 @@ describe('readBlobBytes environment fallbacks', () => { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, ]); - 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); - expect(uri.startsWith('data:application/octet-stream;base64,')).toBe(true); + 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); + } + }; + + 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('does not let a later Blob.prototype.arrayBuffer replacement become payload authority', async () => { + 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 { + 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 { + if (descriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty(Blob.prototype, 'arrayBuffer', descriptor); + } + vi.resetModules(); + } }); 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 +173,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'); + 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 () => { @@ -74,10 +190,12 @@ describe('readBlobBytes environment fallbacks', () => { } } vi.stubGlobal('FileReader', NullErrorReader); - const fakeBlob = { type: 'image/png' } as unknown as Blob; - await expect(blobToDataUri(fakeBlob)).rejects.toThrow( - /FileReader failed to read Blob/, - ); + 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 () => { @@ -91,8 +209,10 @@ describe('readBlobBytes environment fallbacks', () => { } }, ); - const fakeBlob = { type: 'image/png' } as unknown as Blob; - const uri = await blobToDataUri(fakeBlob); - 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 diff --git a/src/converter/base64.preflight.test.ts b/src/converter/base64.preflight.test.ts new file mode 100644 index 00000000..3fdfb114 --- /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(FileReader.prototype, 'readAsArrayBuffer'); + + await expect(blobToDataUri(blob, { maxBytes: 4 })).rejects.toBeInstanceOf( + Base64SizeError, + ); + expect(readSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/converter/base64.ts b/src/converter/base64.ts index 76313e28..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( - `Payload of ${bytes} bytes exceeds the configured limit of ${maxBytes} bytes.`, + `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; @@ -27,6 +27,25 @@ 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() { + 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) { @@ -58,34 +77,191 @@ 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+/]*$/; +const FORGIVING_BASE64_RAW_RE = + /^[A-Za-z0-9+/\t\n\f\r ]*(?:=[\t\n\f\r ]*){0,2}$/; +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.'; +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, + '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 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', +)!.get!; +const BLOB_TYPE_GETTER = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'type', +)!.get!; +const BLOB_ARRAY_BUFFER_METHOD = Object.getOwnPropertyDescriptor( + Blob.prototype, + 'arrayBuffer', +)?.value as unknown; + +type BindableBufferFrom = (...args: never[]) => unknown; +interface BufferAuthority { + from: BindableBufferFrom; +} + +/** Capture Node's Buffer.from authority when present without mutating globals. */ +export function resolveNodeBufferFrom( + buffer: BufferAuthority | undefined, +): BindableBufferFrom | undefined { + if (buffer === undefined) return undefined; + return buffer.from.bind(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 { + buffer: ArrayBufferLike; + byteOffset: number; + byteLength: number; +} + +/** 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); + } + 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, + }; + const probe = new Uint8Array( + slots.buffer, + slots.byteOffset, + slots.byteLength, + ); + void probe; + return slots; + } catch { + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); + } +} -const hasBuffer = typeof globalThis.Buffer !== 'undefined'; +/** Read a genuine live ArrayBuffer view's range without caller-owned accessors. */ +function readArrayBufferViewSlots(input: ArrayBufferView): Uint8ArraySlots { + 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); + } +} /** 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 NODE_BUFFER_FROM!(buffer, byteOffset, byteLength).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 { - const normalized = base64.replace(/\s+/g, ''); + if (typeof base64 !== 'string') { + throw new TypeError(INVALID_BASE64_INPUT_MESSAGE); + } + const normalized = normalizeForgivingBase64(base64); /* v8 ignore start -- browser-only fallback: Node and jsdom always provide Buffer */ if (!hasBuffer) { // eslint-disable-next-line no-undef @@ -97,18 +273,53 @@ 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. */ +function isArrayBuffer(input: unknown): input is ArrayBuffer { + try { + ARRAY_BUFFER_BYTE_LENGTH_GETTER.call(input); + return true; + } catch { + return false; + } +} + +/** 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); + } +} + +/** Return bounded platform Blob MIME metadata without invoking caller overrides. */ +function readBlobType(input: Blob): 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; } -/** Coerce any binary-ish input to a `Uint8Array` view without copying twice. */ +/** Convert only declared binary inputs to a `Uint8Array` without coercion. */ export function toUint8Array( input: ArrayBuffer | ArrayBufferView | Uint8Array, ): Uint8Array { - if (input instanceof Uint8Array) return input; - if (ArrayBuffer.isView(input)) { - return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + if (ARRAY_BUFFER_IS_VIEW(input)) { + if (TYPED_ARRAY_TAG_GETTER.call(input) === 'Uint8Array') { + readUint8ArraySlots(input); + return input as Uint8Array; + } + const { buffer, byteOffset, byteLength } = readArrayBufferViewSlots(input); + return new Uint8Array(buffer, byteOffset, byteLength); } - return new Uint8Array(input); + if (isArrayBuffer(input)) return new Uint8Array(input); + throw new TypeError(INVALID_BINARY_INPUT_MESSAGE); } /** @@ -117,7 +328,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 ( @@ -175,9 +387,10 @@ export function sniffMimeType(bytes: Uint8Array): string | undefined { return 'application/pdf'; } // SVG / XML: look for " { + 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.'); + } + 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), + }; +} + +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('==') + ? 2 + : payload.endsWith('=') + ? 1 + : 0; + 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); +} + +/** 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, + 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) { + 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 output; +} + /** * Encode raw bytes (ArrayBuffer / typed array / Uint8Array) into a base64 * data URI. MIME is taken from `options.mimeType`, otherwise sniffed, otherwise @@ -199,10 +617,11 @@ export function bytesToDataUri( input: ArrayBuffer | ArrayBufferView | Uint8Array, options: EncodeOptions = {}, ): string { + const { mimeType, maxBytes } = resolveEncodeOptions(options); const bytes = toUint8Array(input); - assertSize(bytes.byteLength, options.maxBytes); - const mime = - options.mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; + const { byteLength } = readUint8ArraySlots(bytes); + assertSize(byteLength, maxBytes); + const mime = mimeType ?? sniffMimeType(bytes) ?? 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; } @@ -210,13 +629,19 @@ 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. 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 { - if (typeof blob.arrayBuffer === 'function') { - return new Uint8Array(await blob.arrayBuffer()); + 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); } if (typeof FileReader !== 'undefined') { return new Promise((resolve, reject) => { @@ -241,11 +666,15 @@ export async function blobToDataUri( blob: Blob, options: EncodeOptions = {}, ): Promise { + const { mimeType, maxBytes } = resolveEncodeOptions(options); + assertSize(readBlobSize(blob), maxBytes); + const blobType = + mimeType !== undefined && mimeType.length > 0 ? '' : readBlobType(blob); 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) || + mimeType || + (blobType.length > 0 ? blobType : undefined) || sniffMimeType(bytes) || 'application/octet-stream'; return `data:${mime};base64,${bytesToBase64(bytes)}`; @@ -264,13 +693,22 @@ export function fileToDataUri( * Throws `DataUriParseError` on malformed input. */ export function parseDataUri(dataUri: string): ParsedDataUri { - const match = DATA_URI_RE.exec(dataUri.trim()); + if (typeof dataUri !== 'string') { + throw new DataUriParseError('String is not a valid data URI.'); + } + const match = DATA_URI_RE.exec(dataUri); 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); + const isBase64 = /;base64$/i.test(params); // Capture group 3 always matches (possibly empty), so `?? ''` is defensive. /* v8 ignore next */ const payload = match[3] ?? ''; @@ -279,7 +717,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); } export interface DecodedDataUri { @@ -296,26 +734,25 @@ export function dataUriToBytes( dataUri: string, options: { maxBytes?: number } = {}, ): DecodedDataUri { + const maxBytes = resolveDecodeMaxBytes(options); const { mimeType, isBase64, payload } = parseDataUri(dataUri); let bytes: Uint8Array; if (isBase64) { + if (maxBytes !== undefined) { + const decodedLength = + canonicalBase64DecodedByteLength(payload) ?? + forgivingBase64DecodedByteLength(payload); + if (decodedLength !== undefined) { + assertSize(decodedLength, maxBytes); + } + } bytes = base64ToBytes(payload); } else { - // 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, options.maxBytes); + assertSize(bytes.byteLength, maxBytes); return { mimeType, bytes }; } diff --git a/src/converter/base64BinaryInputRuntime.test.ts b/src/converter/base64BinaryInputRuntime.test.ts new file mode 100644 index 00000000..93ed6cf4 --- /dev/null +++ b/src/converter/base64BinaryInputRuntime.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { bytesToBase64, 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 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 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(); + } 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); + }); + + 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); + }); + + 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); + }); + + it('normalizes detached Uint8Array failures before direct base64 encoding', () => { + const failure = captureFailure(() => bytesToBase64(detachedUint8Array())); + + expect(failure).toMatchObject(INVALID_BINARY_INPUT); + }); +}); diff --git a/src/converter/base64BlobRuntimeInput.test.ts b/src/converter/base64BlobRuntimeInput.test.ts new file mode 100644 index 00000000..9e2c2a8b --- /dev/null +++ b/src/converter/base64BlobRuntimeInput.test.ts @@ -0,0 +1,57 @@ +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); + }); + + 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 diff --git a/src/converter/base64BlobTypeMetadata.test.ts b/src/converter/base64BlobTypeMetadata.test.ts new file mode 100644 index 00000000..d22cc2ee --- /dev/null +++ b/src/converter/base64BlobTypeMetadata.test.ts @@ -0,0 +1,81 @@ +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'); + }); + + it('rejects oversized platform MIME metadata before reading Blob bytes', async () => { + const oversizedType = 'a'.repeat(1_025); + const blob = new Blob([], { type: oversizedType }); + 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( + new RangeError( + 'Blob MIME type must not exceed 1024 UTF-16 code units.', + ), + ); + expect(payloadReadCount).toBe(0); + } finally { + if (platformArrayBufferDescriptor === undefined) { + Reflect.deleteProperty(Blob.prototype, 'arrayBuffer'); + } else { + Object.defineProperty( + Blob.prototype, + 'arrayBuffer', + platformArrayBufferDescriptor, + ); + } + } + }); + + 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 }); + + await expect(blobToDataUri(blob)).resolves.toBe( + `data:${exactBoundaryType};base64,`, + ); + }); +}); diff --git a/src/converter/base64ByteRuntimeInput.test.ts b/src/converter/base64ByteRuntimeInput.test.ts new file mode 100644 index 00000000..836b8a13 --- /dev/null +++ b/src/converter/base64ByteRuntimeInput.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { bytesToBase64, bytesToDataUri } 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); + }); + + 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); + }); +}); 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); + }); +}); 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); + }); +}); 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', + }); + }); +}); 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'); + }); +}); diff --git a/src/converter/base64DataUriPercentOctets.test.ts b/src/converter/base64DataUriPercentOctets.test.ts new file mode 100644 index 00000000..eb082e65 --- /dev/null +++ b/src/converter/base64DataUriPercentOctets.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +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', () => { + expect( + Array.from( + dataUriToBytes( + 'data:application/octet-stream,%00%7F%80%FF', + ).bytes, + ), + ).toEqual([0x00, 0x7f, 0x80, 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); + }); +}); diff --git a/src/converter/base64DataUriRuntimeInput.test.ts b/src/converter/base64DataUriRuntimeInput.test.ts new file mode 100644 index 00000000..a3c0fc30 --- /dev/null +++ b/src/converter/base64DataUriRuntimeInput.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +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', () => { + 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); + }); + + 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); + }); +}); diff --git a/src/converter/base64DecodeResourceBoundary.test.ts b/src/converter/base64DecodeResourceBoundary.test.ts new file mode 100644 index 00000000..3b19d1c8 --- /dev/null +++ b/src/converter/base64DecodeResourceBoundary.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + Base64ParseError, + Base64SizeError, + DataUriParseError, + 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; + + 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(); + }); + + 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('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=', { + 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])); + }); + + it('preserves noncanonical whitespace-compatible fallback decoding', () => { + expect( + dataUriToBytes('data:application/octet-stream;base64,Y Q ==', { + maxBytes: 1, + }).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( + Array.from( + dataUriToBytes('data:text/plain,ab%20c', { maxBytes: 4 }).bytes, + ), + ).toEqual([0x61, 0x62, 0x20, 0x63]); + }); + + it('preserves Unicode percent-encoding fallback decoding', () => { + expect( + Array.from( + dataUriToBytes('data:text/plain,%C3%A9', { maxBytes: 2 }).bytes, + ), + ).toEqual([0xc3, 0xa9]); + }); + + it('preserves malformed percent-encoding error precedence', () => { + expect(() => + dataUriToBytes('data:text/plain,%ZZ', { maxBytes: 0 }), + ).toThrow(DataUriParseError); + }); +}); 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'); + }); +}); 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,`, + ); + }); +}); 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), + ); + }); +}); diff --git a/src/converter/base64RuntimeOptions.test.ts b/src/converter/base64RuntimeOptions.test.ts new file mode 100644 index 00000000..9242ca73 --- /dev/null +++ b/src/converter/base64RuntimeOptions.test.ts @@ -0,0 +1,136 @@ +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(); + }); +}); + +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==', + ); + }); +}); 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', + ); + }); +}); diff --git a/src/converter/base64ViewRuntimeInput.test.ts b/src/converter/base64ViewRuntimeInput.test.ts new file mode 100644 index 00000000..2435b26a --- /dev/null +++ b/src/converter/base64ViewRuntimeInput.test.ts @@ -0,0 +1,91 @@ +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'); + const readCallerAccessor = vi.fn((): never => { + throw privateSentinel; + }); + + Object.defineProperties(view, { + buffer: { get: readCallerAccessor }, + byteOffset: { get: readCallerAccessor }, + byteLength: { get: readCallerAccessor }, + }); + + 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); + 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(); + }); + + 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); + 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); + }); +}); 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, diff --git a/src/converter/sniffMimeRuntimeInput.test.ts b/src/converter/sniffMimeRuntimeInput.test.ts new file mode 100644 index 00000000..2f6a0ea3 --- /dev/null +++ b/src/converter/sniffMimeRuntimeInput.test.ts @@ -0,0 +1,59 @@ +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(); + }); + + 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(); + } + }); +});