From 762ac4ff4850d0bd0fd4e9746d9c06f96df6ce16 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 5 Jun 2026 22:11:49 +0200 Subject: [PATCH] fix: guard native drag drop payloads --- src/hooks/useImageDrop.test.ts | 25 +++++++- src/hooks/useImageDrop.ts | 61 +++++++++++++++--- src/hooks/useTauriDragDropEvent.test.ts | 82 +++++++++++++++++++++++++ src/hooks/useTauriDragDropEvent.ts | 27 +++++++- 4 files changed, 181 insertions(+), 14 deletions(-) create mode 100644 src/hooks/useTauriDragDropEvent.test.ts diff --git a/src/hooks/useImageDrop.test.ts b/src/hooks/useImageDrop.test.ts index fc3e8607..3bf6d694 100644 --- a/src/hooks/useImageDrop.test.ts +++ b/src/hooks/useImageDrop.test.ts @@ -14,7 +14,8 @@ vi.mock('../mock-tauri', () => ({ isTauri: () => tauriMode, })) -type DragDropEvent = { payload: { type: string; paths: string[]; position: { x: number; y: number } } } +type NativeDropPayload = { type: string; paths: string[]; position: { x: number; y: number } } +type DragDropEvent = { payload: unknown } type DragDropCallback = (event: DragDropEvent) => void let capturedDragDropHandler: DragDropCallback | undefined let nativeDropUnlisten = () => { @@ -208,7 +209,7 @@ describe('useImageDrop — Tauri native drag-drop', () => { return renderHook(() => useImageDrop({ containerRef: ref, ...opts })) } - function emitNativeDropEvent(payload: DragDropEvent['payload']) { + function emitNativeDropEvent(payload: unknown) { if (!capturedDragDropHandler) throw new Error('No native drop handler registered') capturedDragDropHandler({ payload }) } @@ -245,6 +246,24 @@ describe('useImageDrop — Tauri native drag-drop', () => { expect(result.current.isDragOver).toBe(false) }) + it('ignores malformed native drag-drop payloads without throwing', async () => { + const { result } = renderImageDropTauri() + + await waitForNativeDropListeners() + + const file = new File(['data'], 'photo.png', { type: 'image/png' }) + act(() => { container.dispatchEvent(createDragEvent('dragover', [file])) }) + expect(result.current.isDragOver).toBe(true) + + expect(() => { + act(() => { + emitNativeDropEvent(null) + }) + }).not.toThrow() + + expect(result.current.isDragOver).toBe(false) + }) + it('copies native image drops into the vault and emits attachment asset URLs', async () => { const onImageUrl = vi.fn() const { invoke, convertFileSrc } = await import('@tauri-apps/api/core') @@ -261,7 +280,7 @@ describe('useImageDrop — Tauri native drag-drop', () => { type: 'drop', paths: ['/tmp/photo.png', '/tmp/readme.txt'], position: { x: 100, y: 100 }, - }) + } satisfies NativeDropPayload) }) await waitFor(() => { diff --git a/src/hooks/useImageDrop.ts b/src/hooks/useImageDrop.ts index 75424685..17e911c0 100644 --- a/src/hooks/useImageDrop.ts +++ b/src/hooks/useImageDrop.ts @@ -22,6 +22,28 @@ type DroppedImagesRequest = { vaultPath: string | undefined onImageUrl: ImageUrlHandler | undefined } +type NativeDropEventRequest = { + event: TauriDropEvent + onImageUrl: ImageUrlHandler | undefined + setIsDragOver: (isDragOver: boolean) => void + vaultPath: string | undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function isNativeDropPayload(payload: unknown): payload is TauriDragDropPayload { + if (!isRecord(payload)) return false + const type = Reflect.get(payload, 'type') + if (typeof type !== 'string') return false + if (type !== 'drop') return true + return isStringArray(Reflect.get(payload, 'paths')) +} function hasImageFiles(dt: DataTransfer): boolean { for (let i = 0; i < dt.items.length; i++) { @@ -81,6 +103,29 @@ function insertDroppedImages({ } } +function handleNativeDropEvent({ + event, + onImageUrl, + setIsDragOver, + vaultPath, +}: NativeDropEventRequest): void { + if (!isNativeDropPayload(event.payload)) { + setIsDragOver(false) + return + } + const { payload } = event + if (payload.type === 'drop') { + setIsDragOver(false) + insertDroppedImages({ + imagePaths: payload.paths.filter(isImagePath), + vaultPath, + onImageUrl, + }) + return + } + setIsDragOver(false) +} + async function registerNativeDropListeners( handler: (event: TauriDropEvent) => void, ): Promise { @@ -155,16 +200,12 @@ export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDr void (async () => { try { const nextUnlisteners = await registerNativeDropListeners((event) => { - if (event.payload.type === 'drop') { - setIsDragOver(false) - insertDroppedImages({ - imagePaths: event.payload.paths.filter(isImagePath), - vaultPath: vaultPathRef.current, - onImageUrl: onImageUrlRef.current, - }) - return - } - setIsDragOver(false) + handleNativeDropEvent({ + event, + onImageUrl: onImageUrlRef.current, + setIsDragOver, + vaultPath: vaultPathRef.current, + }) }) if (mounted) unlisteners = nextUnlisteners else cleanupTauriEventListeners(nextUnlisteners) diff --git a/src/hooks/useTauriDragDropEvent.test.ts b/src/hooks/useTauriDragDropEvent.test.ts new file mode 100644 index 00000000..5ed81169 --- /dev/null +++ b/src/hooks/useTauriDragDropEvent.test.ts @@ -0,0 +1,82 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { useTauriDragDropEvent } from './useTauriDragDropEvent' + +let tauriMode = true + +type NativeDragDropPayload = { + type: string + paths: string[] + position: { x: number; y: number } +} +type CapturedDragDropHandler = (event: { payload: unknown }) => void + +let capturedDragDropHandler: CapturedDragDropHandler | undefined +const unlisten = vi.fn() +const onDragDropEvent = vi.fn((handler: CapturedDragDropHandler) => { + capturedDragDropHandler = handler + return Promise.resolve(unlisten) +}) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => tauriMode, +})) + +vi.mock('@tauri-apps/api/window', () => ({ + getCurrentWindow: () => ({ + onDragDropEvent, + }), +})) + +function emitNativeDragDropPayload(payload: unknown): void { + if (!capturedDragDropHandler) throw new Error('No native drag-drop handler registered') + capturedDragDropHandler({ payload }) +} + +async function waitForNativeDragDropListener(): Promise { + await waitFor(() => { + expect(capturedDragDropHandler).toBeDefined() + }) +} + +describe('useTauriDragDropEvent', () => { + beforeEach(() => { + tauriMode = true + capturedDragDropHandler = undefined + onDragDropEvent.mockClear() + unlisten.mockClear() + }) + + afterEach(() => { + tauriMode = false + capturedDragDropHandler = undefined + }) + + it('does not forward null native drag-drop payloads to consumers', async () => { + const handler = vi.fn() + renderHook(() => useTauriDragDropEvent(handler)) + + await waitForNativeDragDropListener() + + expect(() => { + emitNativeDragDropPayload(null) + }).not.toThrow() + expect(handler).not.toHaveBeenCalled() + }) + + it('forwards valid native drag-drop payloads to consumers', async () => { + const handler = vi.fn() + renderHook(() => useTauriDragDropEvent(handler)) + + await waitForNativeDragDropListener() + + const payload = { + type: 'drop', + paths: ['/tmp/photo.png'], + position: { x: 10, y: 20 }, + } satisfies NativeDragDropPayload + emitNativeDragDropPayload(payload) + + expect(handler).toHaveBeenCalledWith({ payload }) + }) +}) diff --git a/src/hooks/useTauriDragDropEvent.ts b/src/hooks/useTauriDragDropEvent.ts index fa0e7b0d..db7c5624 100644 --- a/src/hooks/useTauriDragDropEvent.ts +++ b/src/hooks/useTauriDragDropEvent.ts @@ -7,9 +7,34 @@ import { cleanupTauriEventListeners } from '../utils/tauriEventCleanup' export type TauriDragDropEvent = TauriEvent type TauriDragDropHandler = (event: TauriDragDropEvent) => void +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string') +} + +function hasDropPosition(value: unknown): boolean { + if (!isRecord(value)) return false + return typeof Reflect.get(value, 'x') === 'number' + && typeof Reflect.get(value, 'y') === 'number' +} + +function isNativeDropPayload(payload: unknown): payload is TauriDragDropPayload { + if (!isRecord(payload)) return false + const type = Reflect.get(payload, 'type') + if (typeof type !== 'string') return false + if (type !== 'drop') return true + return isStringArray(Reflect.get(payload, 'paths')) + && hasDropPosition(Reflect.get(payload, 'position')) +} + async function registerNativeDropListener(handler: TauriDragDropHandler): Promise { const { getCurrentWindow } = await import('@tauri-apps/api/window') - return getCurrentWindow().onDragDropEvent(handler) + return getCurrentWindow().onDragDropEvent((event) => { + if (isNativeDropPayload(event.payload)) handler(event as TauriDragDropEvent) + }) } export function useTauriDragDropEvent(handler: TauriDragDropHandler) {