From 3a740db2f20233f4bafb7a9efd9d52616ab9b415 Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 22 Aug 2026 01:25:10 +0330 Subject: [PATCH] fix(add-window): harden intake admission and destination safety - retain valid magnet clipboard handoffs and reject malformed magnet URLs - normalize destination identity and fail closed on deleted queues - redact malformed media headers and add focused regression coverage --- src-tauri/src/lib.rs | 7 +++-- src/store/useDownloadStore.test.ts | 44 ++++++++++++++++++++++++++++- src/store/useDownloadStore.ts | 5 ++++ src/utils/clipboard.test.ts | 9 +++++- src/utils/downloadLocations.test.ts | 6 ++++ src/utils/downloadLocations.ts | 9 +++++- src/utils/url.ts | 10 ++++++- 7 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fb6ca3c..b392feb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1633,10 +1633,10 @@ fn append_ytdlp_add_header(config: &mut String, header: &str) -> Result { pendingAddBatchName: '', pendingAddRequestContexts: {}, pendingAddRequestVersion: 0, + queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }], }); useDownloadProgressStore.setState({ progressMap: {}, retainedProgressMap: {}, moveProgressMap: {} }); }); @@ -1879,6 +1880,12 @@ describe('useDownloadStore', () => { it('adds to the selected queue without dispatching', async () => { + useDownloadStore.setState({ + queues: [ + { id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }, + { id: 'queue-b', name: 'Downloads', isMain: false } + ] + }); await useDownloadStore.getState().addDownload({ id: 'queue-1', url: 'https://example.com/queue.bin', @@ -1894,6 +1901,23 @@ describe('useDownloadStore', () => { expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything()); }); + it('rejects Add-to-Queue admission when the selected queue was deleted', async () => { + useDownloadStore.setState({ + queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }] + }); + + await expect(useDownloadStore.getState().addDownload({ + id: 'orphaned-queue-row', + url: 'https://example.com/orphaned.bin', + fileName: 'orphaned.bin', + category: 'Other', + dateAdded: '' + }, { type: 'add-to-queue', queueId: 'deleted-queue' })).rejects.toThrow('Queue no longer exists.'); + + expect(useDownloadStore.getState().downloads).toEqual([]); + expect(vi.mocked(ipc.invokeCommand)).not.toHaveBeenCalledWith('db_commit_download_state', expect.anything()); + }); + it('waits for durable admission before dispatching a start-now download', async () => { const disposePersistence = initializeDownloadPersistence('main'); const events: string[] = []; @@ -2170,6 +2194,12 @@ describe('useDownloadStore', () => { }); it('normalizes new Torrent rows before resolving their default destination', async () => { + useDownloadStore.setState({ + queues: [ + { id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }, + { id: 'queue-torrents', name: 'Torrents', isMain: false } + ] + }); await useDownloadStore.getState().addDownload({ id: 'torrent-default', url: 'magnet:?xt=urn:btih:default', @@ -2187,6 +2217,12 @@ describe('useDownloadStore', () => { }); it('inserts a newly staged queue item before paused rows', async () => { + useDownloadStore.setState({ + queues: [ + { id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }, + { id: 'queue-b', name: 'Downloads', isMain: false } + ] + }); useDownloadStore.setState({ downloads: [{ id: 'already-paused', @@ -2216,6 +2252,12 @@ describe('useDownloadStore', () => { }); it('carries a media format estimate into numeric progress state', async () => { + useDownloadStore.setState({ + queues: [ + { id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }, + { id: 'queue-b', name: 'Downloads', isMain: false } + ] + }); await useDownloadStore.getState().addDownload({ id: 'media-estimate', url: 'https://youtube.com/watch?v=estimate', diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 9dd1cde..886bbe5 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -1834,6 +1834,11 @@ export const useDownloadStore = create((set, get) => { }, setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }), addDownload: async (item, action) => { + if (action.type === 'add-to-queue' && !get().queues.some(queue => queue.id === action.queueId)) { + // The Add window can outlive a queue deletion in another view. Never + // persist an orphaned staged row under a queue ID that no longer exists. + throw new Error('Queue no longer exists.'); + } const settings = useSettingsStore.getState(); const normalizedItem = { ...item, diff --git a/src/utils/clipboard.test.ts b/src/utils/clipboard.test.ts index f2ade62..9d9c18c 100644 --- a/src/utils/clipboard.test.ts +++ b/src/utils/clipboard.test.ts @@ -13,16 +13,23 @@ describe('clipboard URL extraction', () => { it('reads only supported, unique download URLs from clipboard text', async () => { vi.mocked(readText).mockResolvedValue( - 'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso mailto:user@example.com' + 'https://example.com/file.zip\nhttps://example.com/file.zip ftp://example.com/file.bin sftp://example.com/file.iso magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567 mailto:user@example.com' ); await expect(readClipboardDownloadUrls()).resolves.toEqual([ 'https://example.com/file.zip', 'ftp://example.com/file.bin', 'sftp://example.com/file.iso', + 'magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567', ]); }); + it('ignores malformed magnet URLs at the clipboard boundary', async () => { + vi.mocked(readText).mockResolvedValue('magnet: magnet:?invalid magnet://tracker/?xt=urn:btih:0123456789abcdef0123456789abcdef01234567'); + + await expect(readClipboardDownloadUrls()).resolves.toEqual([]); + }); + it('preserves clipboard read failures for the caller to handle', async () => { const error = new Error('clipboard unavailable'); vi.mocked(readText).mockRejectedValue(error); diff --git a/src/utils/downloadLocations.test.ts b/src/utils/downloadLocations.test.ts index 036adbd..1e446b0 100644 --- a/src/utils/downloadLocations.test.ts +++ b/src/utils/downloadLocations.test.ts @@ -29,6 +29,12 @@ describe('download locations', () => { expect(downloadLocationEquals('/home/Test', 'Movie.MP4', '/home/test', 'movie.mp4', 'linux')).toBe(false); }); + it('matches destinations with redundant separators without changing platform case rules', () => { + expect(downloadLocationEquals('/Users/test//Downloads/', 'file.zip', '/Users/test/Downloads', 'file.zip', 'macos')).toBe(true); + expect(downloadLocationEquals('//Users/test/Downloads', 'file.zip', '/Users/test/Downloads', 'file.zip', 'macos')).toBe(true); + expect(downloadLocationEquals('\\\\server\\share\\downloads', 'file.zip', '//server//share/downloads/', 'file.zip', 'windows')).toBe(true); + }); + it('uses a remembered Add-window directory only when the setting is enabled', () => { expect(resolveInitialAddWindowLocation( 'D:\\Downloads', diff --git a/src/utils/downloadLocations.ts b/src/utils/downloadLocations.ts index 88dbf12..fe1797c 100644 --- a/src/utils/downloadLocations.ts +++ b/src/utils/downloadLocations.ts @@ -307,7 +307,14 @@ export const downloadLocationEquals = ( os: string ): boolean => { const normalize = (value: string) => { - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + const slashPath = value.replace(/\\/g, '/'); + // Collapse redundant separators without destroying a Windows UNC prefix. + // Destination strings can come from legacy settings as well as the folder + // picker, so lexical equality must not miss the same filesystem target. + const leadingSeparators = slashPath.match(/^\/+/); + const leadingCount = leadingSeparators ? leadingSeparators[0].length : 0; + const prefix = os === 'windows' && leadingCount >= 2 ? '//' : leadingCount > 0 ? '/' : ''; + const normalized = `${prefix}${slashPath.slice(leadingCount).replace(/\/{2,}/g, '/')}`.replace(/\/+$/, ''); return os === 'windows' ? normalized.toLocaleLowerCase() : normalized; diff --git a/src/utils/url.ts b/src/utils/url.ts index 656bb2e..d288a4a 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -11,7 +11,15 @@ export function extractValidDownloadUrls(text: string): string[] { for (const part of parts) { try { const url = new URL(part); - if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:' || url.protocol === 'sftp:') { + const isValidMagnet = url.protocol !== 'magnet:' || ( + !url.username + && !url.password + && !url.hostname + && !url.port + && !url.hash + && url.searchParams.getAll('xt').some(value => /^urn:btih:(?:[0-9a-f]{40}|[a-z2-7]{32})$/i.test(value)) + ); + if ((url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ftp:' || url.protocol === 'sftp:' || url.protocol === 'magnet:') && isValidMagnet) { urls.push(url.toString()); } } catch (e) {