diff --git a/src-tauri/src/extension_server.rs b/src-tauri/src/extension_server.rs index 29cd2ac..085244b 100644 --- a/src-tauri/src/extension_server.rs +++ b/src-tauri/src/extension_server.rs @@ -316,7 +316,17 @@ fn normalize_download(payload: ExtensionRequest) -> Option { matches!(url.scheme(), "http" | "https").then(|| url.to_string()) }); let filename = payload.filename.and_then(|value| sanitize_filename(&value)); - let headers = normalize_headers(payload.headers, payload.media); + // A multi-URL handoff has no per-URL cookie scope. Keep ordinary + // request headers, but drop Cookie headers and the dedicated cookie field + // so a legacy or untrusted caller cannot reuse one session across hosts. + let headers = normalize_headers(payload.headers, payload.media || urls.len() > 1); + let cookies = if !payload.media && urls.len() == 1 { + payload + .cookies + .filter(|value| !value.trim().is_empty()) + } else { + None + }; Some(ExtensionDownload { urls, @@ -329,10 +339,7 @@ fn normalize_download(payload: ExtensionRequest) -> Option { // Cookie header can exceed upstream limits and makes old extension // builds pay for a doomed metadata request before retrying. Ordinary // captured downloads still need their exact request cookies. - cookies: (!payload.media) - .then_some(payload.cookies) - .flatten() - .filter(|value| !value.trim().is_empty()), + cookies, media: payload.media, }) } @@ -568,6 +575,26 @@ mod tests { ); } + #[test] + fn multi_url_capture_drops_cookie_scope_but_preserves_safe_headers() { + let download = normalize_download(ExtensionRequest { + urls: vec![ + "https://one.example/private.zip".to_string(), + "https://two.example/file.zip".to_string(), + ], + referer: None, + silent: true, + filename: None, + headers: Some("Cookie: session=secret\nUser-Agent: Firefox".to_string()), + cookies: Some("session=secret".to_string()), + media: false, + }) + .expect("valid multi-url handoff"); + + assert_eq!(download.cookies, None); + assert_eq!(download.headers.as_deref(), Some("User-Agent: Firefox")); + } + #[test] fn signs_server_proof_with_timestamp_nonce_and_bound_port() { let token = Arc::new(RwLock::new("pairing-token".to_string())); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 95bdd7e..b5240dd 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -539,6 +539,7 @@ export const AddDownloadsModal = () => { setResolvedLocation(finalLocation); const store = useDownloadStore.getState(); const newConflicts: DuplicateConflict[] = []; + const plannedTargets: Array<{ location: string; fileName: string }> = []; for (let i = 0; i < parsedItems.length; i++) { const item = parsedItems[i]; @@ -550,8 +551,25 @@ export const AddDownloadsModal = () => { : destinationOverrides[i] || await categoryLocationForFile(finalFile); const isUrlDupe = store.downloads.some(d => d.url === item.downloadUrl && d.status !== 'failed' && d.status !== 'completed'); + const hasBatchConflict = plannedTargets.some(target => + downloadLocationEquals( + target.location, + target.fileName, + itemLocation, + finalFile, + platform.os + ) + ); if (isUrlDupe) { newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' }); + } else if (hasBatchConflict) { + newConflicts.push({ + id: i.toString(), + fileName: finalFile, + reason: { type: 'file', msg: 'Another selected download uses this destination' }, + resolution: 'rename', + replaceAllowed: false + }); } else { let fileExistsInStore = false; for (const download of store.downloads) { @@ -595,6 +613,7 @@ export const AddDownloadsModal = () => { }); } } + plannedTargets.push({ location: itemLocation, fileName: finalFile }); } if (newConflicts.length > 0) { @@ -646,6 +665,17 @@ export const AddDownloadsModal = () => { const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : ''; let newName = finalFile; let exists = true; + const batchTargets: Array<{ location: string; fileName: string }> = []; + for (const [candidateIndex, candidate] of itemsToAdd.entries()) { + if (!candidate || candidateIndex === idx) continue; + const candidateFile = candidate.isMedia + ? mediaFileNameForSelectedFormat(candidate.file, candidate) + : canonicalizeDownloadFileName(candidate.file); + const candidateLocation = useSharedDestination + ? finalLocation + : destinationOverrides[candidateIndex] || await categoryLocationForFile(candidateFile); + batchTargets.push({ location: candidateLocation, fileName: candidateFile }); + } while (exists && count < 1000) { newName = `${base} (${count})${ext}`; @@ -674,7 +704,14 @@ export const AddDownloadsModal = () => { path: await resolveDownloadFilePath(itemLocation, newName) }); } catch(e) {} - exists = storeHas || diskHas; + const batchHas = batchTargets.some(target => downloadLocationEquals( + target.location, + target.fileName, + itemLocation, + newName, + platform.os + )); + exists = storeHas || diskHas || batchHas; count++; } if (exists) { @@ -1201,7 +1238,7 @@ export const AddDownloadsModal = () => { {metadataSummaryMessage(parsedItems)}
-
diff --git a/src/utils/addDownloadMetadata.test.ts b/src/utils/addDownloadMetadata.test.ts index 65fd851..b6d015a 100644 --- a/src/utils/addDownloadMetadata.test.ts +++ b/src/utils/addDownloadMetadata.test.ts @@ -125,6 +125,31 @@ describe('add download metadata workflow', () => { }); }); + it('replaces a stale filename when a newer handoff supplies a new one', () => { + const existing = row({ + file: 'old-name.zip', + requestContextVersion: 1, + generation: 2 + }); + + const refreshed = reconcileDownloadRows( + existing.sourceUrl, + [existing], + undefined, + new Set(), + () => 'unused', + { [existing.sourceUrl]: 'new-name.zip' }, + { [existing.sourceUrl]: 2 } + ); + + expect(refreshed[0]).toMatchObject({ + file: 'new-name.zip', + status: 'loading', + generation: 3, + requestContextVersion: 2 + }); + }); + it('appends every unseen handoff after the observed version', () => { const merged = appendRequestUrlsAfterVersion( 'https://existing.example/file.zip', diff --git a/src/utils/addDownloadMetadata.ts b/src/utils/addDownloadMetadata.ts index 3f824f7..9f0793b 100644 --- a/src/utils/addDownloadMetadata.ts +++ b/src/utils/addDownloadMetadata.ts @@ -88,8 +88,12 @@ export const reconcileDownloadRows = ( const contextChanged = requestContextVersion !== undefined && requestContextVersion !== preserved.requestContextVersion; if ((forcedMedia && !preserved.isMedia) || contextChanged) { + const requestedFilename = requestFilenames[input.sourceUrl]; return { ...preserved, + file: contextChanged + ? canonicalizeDownloadFileName(requestedFilename || fileNameFromUrl(input.sourceUrl)) + : preserved.file, status: 'loading', generation: preserved.generation + 1, requestContextVersion,