fix(windows): reject multi-file image paste

This commit is contained in:
OrcaWin
2026-07-20 20:00:08 -07:00
parent a924daa5dd
commit f5c2dd0cbb
4 changed files with 66 additions and 14 deletions
+10 -1
View File
@@ -176,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)
@@ -578,7 +584,9 @@ describe('registerClipboardHandlers', () => {
const sourcePath = 'C:\\Users\\alice\\图片\\copied-image.png'
const png = Buffer.from([4, 3, 2, 1])
clipboardReadImageMock.mockReturnValue({ isEmpty: () => true })
clipboardReadBufferMock.mockReturnValue(Buffer.from(`${sourcePath}\0`, 'utf16le'))
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)
@@ -614,6 +622,7 @@ describe('registerClipboardHandlers', () => {
'/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()
+4 -1
View File
@@ -107,7 +107,10 @@ export function registerClipboardHandlers(store: Store): void {
return null
}
const copiedFilePng = await readWindowsClipboardImageFileAsPng(
clipboard.readBuffer('FileNameW'),
{
fileNameW: clipboard.readBuffer('FileNameW'),
shellIdListArray: clipboard.readBuffer('Shell IDList Array')
},
{
createImageFromBuffer: (buffer) => nativeImage.createFromBuffer(buffer),
openFile: (filePath) => open(filePath, 'r')
@@ -9,6 +9,12 @@ 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)
@@ -85,7 +91,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn(() => image(png) as never)
await expect(
readWindowsClipboardImageFileAsPng(fileNameW(filePath), {
readWindowsClipboardImageFileAsPng(clipboardFormats(filePath), {
createImageFromBuffer,
openFile
})
@@ -115,7 +121,25 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(payload, { createImageFromBuffer, openFile })
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()
@@ -128,7 +152,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const openFile = vi.fn().mockRejectedValue(error)
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\shot.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\shot.png'), {
createImageFromBuffer: vi.fn(),
openFile
})
@@ -141,7 +165,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\images.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\images.png'), {
createImageFromBuffer,
openFile: vi.fn().mockResolvedValue(handle)
})
@@ -159,7 +183,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\huge.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\huge.png'), {
createImageFromBuffer,
openFile: vi.fn().mockResolvedValue(handle)
})
@@ -175,7 +199,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\changed.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\changed.png'), {
createImageFromBuffer,
openFile: vi.fn().mockResolvedValue(handle)
})
@@ -189,7 +213,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const handle = fileHandle(Buffer.from('not-an-image'))
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\invalid.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\invalid.png'), {
createImageFromBuffer: vi.fn(() => ({ isEmpty: () => true }) as never),
openFile: vi.fn().mockResolvedValue(handle)
})
@@ -201,7 +225,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\pixel-bomb.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\pixel-bomb.png'), {
createImageFromBuffer,
openFile: vi.fn().mockResolvedValue(handle)
})
@@ -220,7 +244,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const createImageFromBuffer = vi.fn()
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\marker-flood.jpg'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\marker-flood.jpg'), {
createImageFromBuffer,
openFile: vi.fn().mockResolvedValue(fileHandle(source))
})
@@ -232,7 +256,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
it('bounds decoded dimensions and converted PNG bytes', async () => {
const oversizedDimensions = fileHandle(pngHeader())
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\wide.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\wide.png'), {
createImageFromBuffer: vi.fn(
() =>
({
@@ -247,7 +271,7 @@ describe('readWindowsClipboardImageFileAsPng', () => {
const oversizedPng = fileHandle(pngHeader())
await expect(
readWindowsClipboardImageFileAsPng(fileNameW('C:\\expanded.png'), {
readWindowsClipboardImageFileAsPng(clipboardFormats('C:\\expanded.png'), {
createImageFromBuffer: vi.fn(
() => image(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) as never
),
@@ -13,6 +13,11 @@ type WindowsClipboardImageFileDeps = {
openFile: (filePath: string) => Promise<ClipboardImageFileHandle>
}
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'])
@@ -63,6 +68,14 @@ function decodeFileNameW(value: Buffer): string | 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 ||
@@ -140,9 +153,12 @@ async function readStableFile(
}
export async function readWindowsClipboardImageFileAsPng(
fileNameW: Buffer,
{ fileNameW, shellIdListArray }: WindowsClipboardImageFileFormats,
{ createImageFromBuffer, openFile }: WindowsClipboardImageFileDeps
): Promise<Buffer | null> {
if (!hasAtMostOneShellItem(shellIdListArray)) {
return null
}
const filePath = decodeFileNameW(fileNameW)
if (!filePath) {
return null