mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-22 08:56:44 +00:00
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:
@@ -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,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, };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|| (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}`,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user