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>
This commit is contained in:
parent
cabb5dc05a
commit
ede57cf723
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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<FileHandle, 'close' | 'read' | 'stat'>
|
||||
|
||||
type WindowsClipboardImageFileDeps = {
|
||||
createImageFromBuffer: (buffer: Buffer) => NativeImage
|
||||
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'])
|
||||
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<Buffer | null> {
|
||||
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<Buffer | null> {
|
||||
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<ReturnType<ClipboardImageFileHandle['stat']>>
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue