From ede57cf723acfef6a9e7e1d9639b47c282012e21 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Mon, 20 Jul 2026 23:16:36 -0400 Subject: [PATCH] fix(windows): paste copied image files (#9640) * fix(windows): paste copied image files * fix(windows): reject multi-file image paste --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../window/clipboard-ipc-handlers.test.ts | 91 ++++++ src/main/window/clipboard-ipc-handlers.ts | 18 +- .../clipboard-windows-image-file.test.ts | 282 ++++++++++++++++++ .../window/clipboard-windows-image-file.ts | 218 ++++++++++++++ 4 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 src/main/window/clipboard-windows-image-file.test.ts create mode 100644 src/main/window/clipboard-windows-image-file.ts diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index e679e4ae0..1e0c32b3d 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -16,8 +16,10 @@ const { fsReaddirMock, fsRmMock, fsWriteFileMock, + fsOpenMock, fsStatMock, clipboardReadTextMock, + clipboardReadBufferMock, clipboardWriteTextMock, clipboardReadImageMock, clipboardWriteImageMock, @@ -47,8 +49,10 @@ const { fsReaddirMock: vi.fn(), fsRmMock: vi.fn(), fsWriteFileMock: vi.fn(), + fsOpenMock: vi.fn(), fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), + clipboardReadBufferMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), clipboardWriteImageMock: vi.fn(), @@ -67,6 +71,7 @@ vi.mock('node:fs/promises', () => ({ mkdir: fsMkdirMock, readdir: fsReaddirMock, rm: fsRmMock, + open: fsOpenMock, stat: fsStatMock, default: { writeFile: fsWriteFileMock @@ -91,6 +96,7 @@ vi.mock('electron', () => ({ }, clipboard: { readText: clipboardReadTextMock, + readBuffer: clipboardReadBufferMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, writeImage: clipboardWriteImageMock, @@ -170,6 +176,12 @@ function dirent(name: string, directory = true): { name: string; isDirectory: () return { name, isDirectory: () => directory } } +function shellIdListArray(childCount: number): Buffer { + const value = Buffer.alloc(4 + 4 * (childCount + 1)) + value.writeUInt32LE(childCount) + return value +} + describe('registerClipboardHandlers', () => { beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) @@ -186,9 +198,12 @@ describe('registerClipboardHandlers', () => { fsRmMock.mockReset() fsRmMock.mockResolvedValue(undefined) fsWriteFileMock.mockReset() + fsOpenMock.mockReset() fsStatMock.mockReset() fsStatMock.mockResolvedValue({}) clipboardReadTextMock.mockReset() + clipboardReadBufferMock.mockReset() + clipboardReadBufferMock.mockReturnValue(Buffer.alloc(0)) clipboardWriteTextMock.mockReset() clipboardReadImageMock.mockReset() clipboardWriteImageMock.mockReset() @@ -542,9 +557,85 @@ describe('registerClipboardHandlers', () => { handlers.get('clipboard:saveImageAsTempFile')?.(makeClipboardEvent(), undefined) ).resolves.toBe(expectedPath) expect(fsWriteFileMock).toHaveBeenCalledWith(expectedPath, png) + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) + it('does not inspect FileNameW when an empty image clipboard is read outside Windows', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), undefined)).resolves.toBeNull() + expect(clipboardReadBufferMock).not.toHaveBeenCalled() + expect(fsOpenMock).not.toHaveBeenCalled() + expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + + it('routes a Windows Explorer FileNameW image through the target-aware attachment flow', async () => { + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png' + const png = Buffer.from([4, 3, 2, 1]) + clipboardReadImageMock.mockReturnValue({ isEmpty: () => true }) + clipboardReadBufferMock.mockImplementation((format: string) => + format === 'FileNameW' ? Buffer.from(`${sourcePath}\0`, 'utf16le') : shellIdListArray(1) + ) + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(1, 16) + source.writeUInt32BE(1, 20) + const close = vi.fn().mockResolvedValue(undefined) + fsOpenMock.mockResolvedValue({ + close, + stat: vi.fn().mockResolvedValue({ isFile: () => true, size: source.byteLength }), + read: vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min(Math.max(source.byteLength - position, 0), length) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ height: 1, width: 1 }), + isEmpty: () => false, + toPNG: () => png + }) + const writeFileBase64 = vi.fn().mockResolvedValue(undefined) + getSshFilesystemProviderMock.mockReturnValue({ + getTempDir: vi.fn().mockResolvedValue('/var/tmp'), + writeFileBase64 + }) + + try { + registerClipboardHandlers({} as never) + + const handler = getRegisteredHandlers().get('clipboard:saveImageAsTempFile') + await expect(handler?.(makeClipboardEvent(), { connectionId: 'ssh-1' })).resolves.toBe( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png' + ) + expect(clipboardReadBufferMock).toHaveBeenCalledWith('FileNameW') + expect(clipboardReadBufferMock).toHaveBeenCalledWith('Shell IDList Array') + expect(fsOpenMock).toHaveBeenCalledWith(sourcePath, 'r') + expect(nativeImageCreateFromBufferMock).toHaveBeenCalledWith(source) + expect(close).toHaveBeenCalled() + expect(writeFileBase64).toHaveBeenCalledWith( + '/var/tmp/orca-paste-1760000000000-00000000-0000-4000-8000-000000000000.png', + png.toString('base64') + ) + expect(fsWriteFileMock).not.toHaveBeenCalled() + } finally { + platformSpy.mockRestore() + } + }) + it('saves clipboard images through the selected remote runtime host', async () => { const png = Buffer.alloc(512 * 1024) const contentBase64 = png.toString('base64') diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6c98d6bb..47b4d5d47 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -7,7 +7,7 @@ import { type WebContents } from 'electron' import { spawn } from 'node:child_process' -import { stat } from 'node:fs/promises' +import { open, stat } from 'node:fs/promises' import type { Store } from '../persistence' import { isENOENT, PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' import { @@ -34,6 +34,7 @@ import { writeRemoteFileToClipboard } from './clipboard-remote-file-copy' import { saveClipboardImageBufferInRuntime } from './clipboard-runtime-image-upload' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' let trustedClipboardRendererWebContentsId: number | null = null @@ -102,7 +103,20 @@ export function registerClipboardHandlers(store: Store): void { assertTrustedClipboardSender(event) const image = clipboard.readImage() if (image.isEmpty()) { - return null + if (process.platform !== 'win32') { + return null + } + const copiedFilePng = await readWindowsClipboardImageFileAsPng( + { + fileNameW: clipboard.readBuffer('FileNameW'), + shellIdListArray: clipboard.readBuffer('Shell IDList Array') + }, + { + createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer), + openFile: (filePath) => open(filePath, 'r') + } + ) + return copiedFilePng ? saveClipboardImageBufferForTarget(copiedFilePng, args) : null } assertClipboardImageDimensionsWithinLimit(image.getSize()) return saveClipboardImageBufferForTarget(image.toPNG(), args) diff --git a/src/main/window/clipboard-windows-image-file.test.ts b/src/main/window/clipboard-windows-image-file.test.ts new file mode 100644 index 000000000..d69762066 --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.test.ts @@ -0,0 +1,282 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CLIPBOARD_IMAGE_MAX_PIXELS, + CLIPBOARD_IMAGE_MAX_SOURCE_BYTES +} from '../../shared/clipboard-image' +import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-file' + +function fileNameW(filePath: string): Buffer { + return Buffer.from(`${filePath}\0`, 'utf16le') +} + +function clipboardFormats(filePath: string, shellItemCount = 1) { + const shellIdListArray = Buffer.alloc(4 + 4 * (shellItemCount + 1)) + shellIdListArray.writeUInt32LE(shellItemCount) + return { fileNameW: fileNameW(filePath), shellIdListArray } +} + +function pngHeader(width = 10, height = 10): Buffer { + const source = Buffer.alloc(24) + Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(source) + source.writeUInt32BE(13, 8) + source.write('IHDR', 12, 'ascii') + source.writeUInt32BE(width, 16) + source.writeUInt32BE(height, 20) + return source +} + +function jpegHeader(width = 10, height = 10): Buffer { + return Buffer.from([ + 0xff, + 0xd8, + 0xff, + 0xe0, + 0x00, + 0x02, + 0xff, + 0xc2, + 0x00, + 0x07, + 0x08, + height >> 8, + height & 0xff, + width >> 8, + width & 0xff + ]) +} + +function image(png = Buffer.from([4, 3, 2, 1])) { + return { + getSize: () => ({ height: 10, width: 10 }), + isEmpty: () => false, + toPNG: () => png + } +} + +function fileHandle( + source: Buffer, + options: { chunkSize?: number; isFile?: boolean; size?: number } = {} +) { + const close = vi.fn().mockResolvedValue(undefined) + const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => { + const bytesRead = Math.min( + Math.max(source.byteLength - position, 0), + length, + options.chunkSize ?? Number.POSITIVE_INFINITY + ) + source.copy(buffer, offset, position, position + bytesRead) + return { buffer, bytesRead } + }) + return { + close, + read, + stat: vi.fn().mockResolvedValue({ + isFile: () => options.isFile ?? true, + size: options.size ?? source.byteLength + }) + } +} + +describe('readWindowsClipboardImageFileAsPng', () => { + it.each([ + 'C:\\Users\\alice\\图片\\shot.PNG', + '\\\\server\\share\\shot.jpeg', + '\\\\?\\C:\\Users\\alice\\shot.jpg', + '\\\\?\\UNC\\server\\share\\shot.png' + ])('decodes and converts a bounded FileNameW path: %s', async (filePath) => { + const source = filePath.toLowerCase().endsWith('.png') ? pngHeader() : jpegHeader() + const png = Buffer.from([9, 8, 7]) + const handle = fileHandle(source, { chunkSize: 3 }) + const openFile = vi.fn().mockResolvedValue(handle) + const createImageFromBuffer = vi.fn(() => image(png) as never) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats(filePath), { + createImageFromBuffer, + openFile + }) + ).resolves.toEqual(png) + + expect(openFile).toHaveBeenCalledWith(filePath) + expect(handle.read.mock.calls.length).toBeGreaterThan(1) + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).toHaveBeenCalledWith(source) + }) + + it.each([ + ['empty', Buffer.alloc(0)], + ['odd byte count', Buffer.from([65, 0, 0])], + ['missing terminator', Buffer.from('C:\\shot.png', 'utf16le')], + ['relative path', fileNameW('shot.png')], + ['drive-relative path', fileNameW('C:shot.png')], + ['rooted drive-relative path', fileNameW('\\shot.png')], + ['device namespace', fileNameW('\\\\.\\pipe\\shot.png')], + ['UNC named-pipe namespace', fileNameW('\\\\server\\pipe\\shot.png')], + ['extended UNC named-pipe namespace', fileNameW('\\\\?\\UNC\\server\\pipe\\shot.png')], + ['multiple paths', Buffer.from('C:\\one.png\0C:\\two.png\0', 'utf16le')], + ['unsupported image type', fileNameW('C:\\shot.webp')], + ['oversized payload', Buffer.alloc(64 * 1024 + 2)] + ])('ignores malformed or unsupported %s payloads', async (_name, payload) => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng( + { fileNameW: payload, shellIdListArray: Buffer.alloc(0) }, + { createImageFromBuffer, openFile } + ) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects an Explorer multi-selection when FileNameW exposes only its first path', async () => { + const openFile = vi.fn() + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\one.png', 2), { + createImageFromBuffer, + openFile + }) + ).resolves.toBeNull() + + expect(openFile).not.toHaveBeenCalled() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores missing, inaccessible, and looping paths', async () => { + for (const code of ['ENOENT', 'EACCES', 'ELOOP']) { + const error = Object.assign(new Error(code), { code }) + const openFile = vi.fn().mockRejectedValue(error) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\shot.png'), { + createImageFromBuffer: vi.fn(), + openFile + }) + ).resolves.toBeNull() + } + }) + + it('ignores directories and closes their handles', async () => { + const handle = fileHandle(Buffer.alloc(0), { isFile: false }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\images.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('rejects oversized sources before reading or decoding', async () => { + const handle = fileHandle(Buffer.alloc(0), { + size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 + }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\huge.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(handle.read).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores a source that changes size after handle validation', async () => { + const handle = fileHandle(Buffer.from('grew'), { size: 3 }) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\changed.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + + expect(handle.close).toHaveBeenCalledOnce() + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('ignores image bytes the native decoder cannot decode', async () => { + const handle = fileHandle(Buffer.from('not-an-image')) + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\invalid.png'), { + createImageFromBuffer: vi.fn(() => ({ isEmpty: () => true }) as never), + openFile: vi.fn().mockResolvedValue(handle) + }) + ).resolves.toBeNull() + }) + + it('rejects oversized encoded dimensions before native decoding', async () => { + const handle = fileHandle(pngHeader(CLIPBOARD_IMAGE_MAX_PIXELS + 1, 1)) + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\pixel-bomb.png'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(handle) + }) + ).rejects.toThrow('Clipboard image is too large') + + expect(createImageFromBuffer).not.toHaveBeenCalled() + expect(handle.close).toHaveBeenCalledOnce() + }) + + it('bounds malformed JPEG marker scanning before native decoding', async () => { + const source = Buffer.alloc(2 + 2 * 4097) + source.set([0xff, 0xd8]) + for (let offset = 2; offset < source.byteLength; offset += 2) { + source.set([0xff, 0x01], offset) + } + const createImageFromBuffer = vi.fn() + + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\marker-flood.jpg'), { + createImageFromBuffer, + openFile: vi.fn().mockResolvedValue(fileHandle(source)) + }) + ).resolves.toBeNull() + + expect(createImageFromBuffer).not.toHaveBeenCalled() + }) + + it('bounds decoded dimensions and converted PNG bytes', async () => { + const oversizedDimensions = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\wide.png'), { + createImageFromBuffer: vi.fn( + () => + ({ + getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), + isEmpty: () => false, + toPNG: vi.fn() + }) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedDimensions) + }) + ).rejects.toThrow('Clipboard image is too large') + + const oversizedPng = fileHandle(pngHeader()) + await expect( + readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\expanded.png'), { + createImageFromBuffer: vi.fn( + () => image(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) as never + ), + openFile: vi.fn().mockResolvedValue(oversizedPng) + }) + ).rejects.toThrow('Clipboard image is too large') + }) +}) diff --git a/src/main/window/clipboard-windows-image-file.ts b/src/main/window/clipboard-windows-image-file.ts new file mode 100644 index 000000000..3c26bb70c --- /dev/null +++ b/src/main/window/clipboard-windows-image-file.ts @@ -0,0 +1,218 @@ +import { win32 } from 'node:path' +import type { FileHandle } from 'node:fs/promises' +import type { NativeImage } from 'electron' +import { + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../shared/clipboard-image' + +type ClipboardImageFileHandle = Pick + +type WindowsClipboardImageFileDeps = { + createImageFromBuffer: (buffer: Buffer) => NativeImage + openFile: (filePath: string) => Promise +} + +type WindowsClipboardImageFileFormats = { + fileNameW: Buffer + shellIdListArray: Buffer +} + +const FILE_NAME_W_MAX_BYTES = 64 * 1024 +const FILE_READ_MAX_CALLS = 1024 +const IMAGE_FILE_EXTENSION_SET = new Set(['.jpeg', '.jpg', '.png']) +const JPEG_DIMENSION_SCAN_MAX_BYTES = 1024 * 1024 +const JPEG_DIMENSION_SCAN_MAX_MARKERS = 4096 +const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]) +const JPEG_START_OF_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf +]) + +function isOrdinaryUncShare(share: string | undefined): boolean { + return typeof share === 'string' && share.toLowerCase() !== 'pipe' +} + +function isFullyQualifiedWindowsPath(filePath: string): boolean { + if (/^[A-Za-z]:[\\/]/.test(filePath)) { + return true + } + if (/^\\\\\?\\[A-Za-z]:\\/.test(filePath)) { + return true + } + const extendedUnc = /^\\\\\?\\UNC\\[^\\/]+\\([^\\/]+)(?:\\|$)/i.exec(filePath) + if (extendedUnc) { + return isOrdinaryUncShare(extendedUnc[1]) + } + const unc = /^[/\\]{2}(?![?.][/\\])[^/\\]+[/\\]([^/\\]+)(?:[/\\]|$)/.exec(filePath) + return isOrdinaryUncShare(unc?.[1]) +} + +function decodeFileNameW(value: Buffer): string | null { + if ( + value.byteLength < 2 || + value.byteLength > FILE_NAME_W_MAX_BYTES || + value.byteLength % 2 !== 0 || + value.readUInt16LE(value.byteLength - 2) !== 0 + ) { + return null + } + + let end = value.byteLength - 2 + while (end >= 2 && value.readUInt16LE(end - 2) === 0) { + end -= 2 + } + const filePath = value.subarray(0, end).toString('utf16le') + if (!filePath || filePath.includes('\0') || !isFullyQualifiedWindowsPath(filePath)) { + return null + } + return IMAGE_FILE_EXTENSION_SET.has(win32.extname(filePath).toLowerCase()) ? filePath : null +} + +function hasAtMostOneShellItem(value: Buffer): boolean { + if (value.byteLength === 0) { + return true + } + // Why: Explorer's FileNameW exposes only the first path even when its CIDA has multiple items. + return value.byteLength >= 12 && value.readUInt32LE(0) === 1 +} + +function readPngDimensions(source: Buffer): { height: number; width: number } | null { + if ( + source.byteLength < 24 || + !source.subarray(0, PNG_SIGNATURE.byteLength).equals(PNG_SIGNATURE) || + source.readUInt32BE(8) !== 13 || + source.toString('ascii', 12, 16) !== 'IHDR' + ) { + return null + } + return { height: source.readUInt32BE(20), width: source.readUInt32BE(16) } +} + +function readJpegDimensions(source: Buffer): { height: number; width: number } | null { + if (source.byteLength < 4 || source[0] !== 0xff || source[1] !== 0xd8) { + return null + } + let offset = 2 + let markersRead = 0 + const scanEnd = Math.min(source.byteLength, JPEG_DIMENSION_SCAN_MAX_BYTES) + while (offset < scanEnd && markersRead < JPEG_DIMENSION_SCAN_MAX_MARKERS) { + while (offset < scanEnd && source[offset] === 0xff) { + offset += 1 + } + const marker = source[offset] + offset += 1 + markersRead += 1 + if (marker === undefined || marker === 0x00 || marker === 0xd9 || marker === 0xda) { + return null + } + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) { + continue + } + if (offset + 2 > source.byteLength) { + return null + } + const segmentLength = source.readUInt16BE(offset) + if ( + segmentLength < 2 || + offset + segmentLength > source.byteLength || + offset + segmentLength > scanEnd + ) { + return null + } + if (JPEG_START_OF_FRAME_MARKERS.has(marker)) { + if (segmentLength < 7) { + return null + } + return { height: source.readUInt16BE(offset + 3), width: source.readUInt16BE(offset + 5) } + } + offset += segmentLength + } + return null +} + +function readImageDimensions(source: Buffer): { height: number; width: number } | null { + return readPngDimensions(source) ?? readJpegDimensions(source) +} + +async function readStableFile( + handle: ClipboardImageFileHandle, + expectedSize: number +): Promise { + const buffer = Buffer.alloc(expectedSize + 1) + let bytesRead = 0 + let readCalls = 0 + while (bytesRead < buffer.byteLength && readCalls < FILE_READ_MAX_CALLS) { + const result = await handle.read(buffer, bytesRead, buffer.byteLength - bytesRead, bytesRead) + readCalls += 1 + if (result.bytesRead === 0) { + break + } + bytesRead += result.bytesRead + } + return bytesRead === expectedSize ? buffer.subarray(0, bytesRead) : null +} + +export async function readWindowsClipboardImageFileAsPng( + { fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats, + { createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps +): Promise { + if (!hasAtMostOneShellItem(shellIdListArray)) { + return null + } + const filePath = decodeFileNameW(fileNameW) + if (!filePath) { + return null + } + + let handle: ClipboardImageFileHandle + try { + // Why: one handle keeps validation and the bounded read on the same file if its path changes. + handle = await openFile(filePath) + } catch { + return null + } + + let source: Buffer | null = null + try { + let file: Awaited> + try { + file = await handle.stat() + } catch { + return null + } + if (!file.isFile() || !Number.isSafeInteger(file.size) || file.size < 0) { + return null + } + assertClipboardImageByteLengthWithinLimit(file.size) + try { + source = await readStableFile(handle, file.size) + } catch { + return null + } + } finally { + await handle.close().catch(() => {}) + } + if (!source) { + return null + } + const encodedDimensions = readImageDimensions(source) + if (!encodedDimensions) { + return null + } + // Why: reject pixel bombs from metadata before NativeImage allocates decoded pixels. + assertClipboardImageDimensionsWithinLimit(encodedDimensions) + + let image: NativeImage + try { + image = createImageFromBuffer(source) + } catch { + return null + } + if (image.isEmpty()) { + return null + } + assertClipboardImageDimensionsWithinLimit(image.getSize()) + const png = image.toPNG() + assertClipboardImageByteLengthWithinLimit(png.byteLength) + return png +}