fix(downloads): harden automatic capture and aria2 transfers

- Pass prepared redirect URIs to Aria2 while preserving stable source identities.

- Fence effective-connection telemetry and retry lifecycle transitions by control epoch.

- Keep credentialed routes conservative across redirects, mirrors, and inline URL credentials.

- Preflight destination access and preserve actionable retryable permission errors in the UI.

- Add adversarial regression coverage for ranges, redirects, retries, telemetry, and enqueue failures.
This commit is contained in:
NimBold
2026-08-15 14:31:38 +03:30
parent 92cfaa26ce
commit f77fd0be3f
14 changed files with 846 additions and 90 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadErrorKind = "nameResolution";
export type DownloadErrorKind = "nameResolution" | "destinationAccess";
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, active_connections?: number, requested_connections?: number, effective_connections?: number, uploaded_bytes?: number, upload_speed?: string, num_seeders?: number, torrent_seeded_seconds?: number, };
+4 -2
View File
@@ -1496,7 +1496,8 @@ export const AddDownloadsModal = () => {
lastError: undefined
}, pendingAction);
if (!replaced) {
throw new Error(t($ => $.addDownloads.backendRejectedStart));
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
// The existing row was updated in place; do not create a
@@ -1625,7 +1626,8 @@ export const AddDownloadsModal = () => {
sizeBytes: item.sizeBytes
}, action);
if (!added) {
throw new Error(t($ => $.addDownloads.backendRejectedStart));
const rejected = useDownloadStore.getState().downloads.find(download => download.id === id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
addedCount += 1;
} catch (e) {
+5 -1
View File
@@ -334,7 +334,11 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div>
<span
title={
download.lastError && (download.status === 'failed' || download.status === 'retrying')
download.lastError && (
download.status === 'failed'
|| download.status === 'retrying'
|| download.lastErrorKind === 'destinationAccess'
)
? download.lastError
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${downloadStatusLabel} #${queueIndex + 1}`
+6 -1
View File
@@ -227,9 +227,14 @@ describe('Properties window bridge', () => {
size_is_final: true,
active_connections: 3,
requested_connections: 8,
effective_connections: 1,
},
});
expect(normalSnapshot).toMatchObject({ activeConnections: 3, requestedConnections: 8 });
expect(normalSnapshot).toMatchObject({
activeConnections: 3,
requestedConnections: 8,
effectiveConnections: 1,
});
expect(normalSnapshot).not.toHaveProperty('connectedPeers');
});
+6
View File
@@ -179,6 +179,7 @@ export type PropertiesSnapshot = SafePropertiesFields & {
lastResolverFallback?: boolean;
activeConnections?: number;
requestedConnections?: number;
effectiveConnections?: number;
uploadSpeed?: string;
torrentConnectedPeers?: number;
torrentConnectedSeeders?: number;
@@ -437,6 +438,11 @@ const copyWithoutSecrets = (
&& live.progress.requested_connections !== undefined
? { requestedConnections: live.progress.requested_connections }
: {}),
...(item.isTorrent !== true
&& item.isMedia !== true
&& live.progress.effective_connections !== undefined
? { effectiveConnections: live.progress.effective_connections }
: {}),
...(live.progress.uploaded_bytes !== undefined
? { torrentUploadedBytes: live.progress.uploaded_bytes }
: {}),
+29
View File
@@ -1082,6 +1082,35 @@ describe('useDownloadStore', () => {
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('keeps destination permission failures retryable before backend admission', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'enqueue_download') {
throw new Error('Internal error: destination access retryable: Firelink could not write to the selected folder; grant access and retry');
}
return undefined;
});
useDownloadStore.setState({
downloads: [{
id: 'destination-permission',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'ready',
category: 'Other',
dateAdded: ''
}] as any[],
backendRegisteredIds: new Set()
});
await expect(dispatchItem('destination-permission')).resolves.toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'ready',
lastError: 'Firelink could not write to the selected folder; grant access and retry',
lastErrorKind: 'destinationAccess'
});
});
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
const settings = {
siteLogins: [
+27 -3
View File
@@ -3,6 +3,7 @@ import { info } from '../utils/logger';
import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
@@ -258,6 +259,18 @@ export class SystemProxyResolutionError extends Error {
const isSystemProxyConfigurationError = (error: unknown): boolean =>
error instanceof SystemProxyResolutionError;
const DESTINATION_ACCESS_ERROR_MARKER = 'destination access retryable:';
const isRetryableDestinationAccessError = (error: unknown): boolean =>
errorMessage(error).toLowerCase().includes(DESTINATION_ACCESS_ERROR_MARKER);
const destinationAccessErrorMessage = (message: string): string => {
const markerIndex = message.toLowerCase().indexOf(DESTINATION_ACCESS_ERROR_MARKER);
if (markerIndex === -1) return message;
const detail = message.slice(markerIndex + DESTINATION_ACCESS_ERROR_MARKER.length).trim();
return detail || message;
};
const stripSensitiveMediaHeaders = (value: string | null | undefined): string =>
(value || '')
.split(/\r?\n/)
@@ -442,7 +455,10 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]);
useDownloadStore.getState().updateDownload(id, { lastError: undefined });
useDownloadStore.getState().updateDownload(id, {
lastError: undefined,
lastErrorKind: undefined
});
return true;
} catch (e) {
console.error(`Failed to dispatch ${id}:`, e);
@@ -451,9 +467,17 @@ async function dispatchItemInternal(id: string, proxyOverride?: string | null):
}
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
const proxyBlocked = isSystemProxyConfigurationError(e);
const destinationAccessBlocked = isRetryableDestinationAccessError(e);
const message = errorMessage(e);
useDownloadStore.getState().updateDownload(id, {
status: proxyBlocked ? 'queued' : 'failed',
lastError: errorMessage(e)
status: proxyBlocked ? 'queued' : destinationAccessBlocked ? 'ready' : 'failed',
hasBeenDispatched: false,
lastErrorKind: destinationAccessBlocked
? ('destinationAccess' as DownloadErrorKind)
: undefined,
lastError: destinationAccessBlocked
? destinationAccessErrorMessage(message)
: message
});
}
return false;
+7
View File
@@ -8,6 +8,13 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
export const classifyDownloadError = (message: unknown): DownloadErrorKind | undefined => {
if (typeof message !== 'string') return undefined;
const lower = message.toLowerCase();
if (
lower.includes('destination access retryable')
|| lower.includes('could not write to the selected folder')
|| lower.includes('selected folder could not be verified')
) {
return 'destinationAccess';
}
if (
lower.includes('aria2 error code 19')
|| (
+15
View File
@@ -58,6 +58,21 @@ describe('Properties connection presentation', () => {
});
});
it('shows effective Aria2 connections when the transfer is degraded', () => {
expect(getPropertiesConnectionPresentation({
isMedia: false,
isTorrent: false,
connections: 16,
activeConnections: 1,
requestedConnections: 16,
effectiveConnections: 1,
})).toMatchObject({
kind: 'aria2',
labelKey: 'connections',
value: '1 / 1',
});
});
it('does not use tellActive connections for the Torrent header', () => {
expect(getPropertiesConnectionPresentation({
isMedia: false,
+2 -2
View File
@@ -25,7 +25,7 @@ export const getPropertiesProgress = (
: resolveDownloadFraction(snapshot);
export const getPropertiesConnectionPresentation = (
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
snapshot: Pick<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'effectiveConnections' | 'torrentConnectedPeers' | 'torrentConnectedSeeders'>,
): PropertiesConnectionPresentation => {
if (snapshot.isMedia === true) {
return {
@@ -53,6 +53,6 @@ export const getPropertiesConnectionPresentation = (
kind: 'aria2',
showHeaderMetric: true,
labelKey: 'connections',
value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.requestedConnections ?? snapshot.connections)}`,
value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.effectiveConnections ?? snapshot.requestedConnections ?? snapshot.connections)}`,
};
};