fix: harden audited persistence and download paths

This commit is contained in:
NimBold
2026-07-17 16:27:39 +03:30
parent 0447d1cab7
commit f4b830414d
11 changed files with 586 additions and 45 deletions
+35 -18
View File
@@ -4,6 +4,7 @@ import {
type ListRowDensity,
type SettingsState,
SettingsTab,
runSettingsPersistenceTransaction,
useSettingsStore
} from '../store/useSettingsStore';
import {
@@ -288,6 +289,8 @@ const engineRunId = useRef(0);
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
const [isSavingLogin, setIsSavingLogin] = useState(false);
const saveLoginInFlight = useRef(false);
const [loginFieldErrors, setLoginFieldErrors] = useState<{
pattern?: string;
username?: string;
@@ -546,6 +549,7 @@ runEngineChecks(false);
};
const handleAddLogin = async () => {
if (saveLoginInFlight.current) return;
const fieldErrors: typeof loginFieldErrors = {};
if (!loginPattern.trim()) {
fieldErrors.pattern = 'URL pattern is required.';
@@ -571,22 +575,31 @@ runEngineChecks(false);
setLoginError('Grant credential-store access before saving a site login.');
return;
}
if (loginPass) {
try {
await invoke('set_keychain_password', { id, password: loginPass });
} catch (e) {
console.error("Failed to save password to keychain:", e);
setLoginError("Failed to save password securely.");
return;
}
saveLoginInFlight.current = true;
setIsSavingLogin(true);
try {
await runSettingsPersistenceTransaction(async () => {
await invoke('save_site_login', {
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim(),
password: loginPass
});
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
});
} catch (e) {
console.error("Failed to save site login:", e);
setLoginError("Failed to save site credential securely.");
return;
} finally {
saveLoginInFlight.current = false;
setIsSavingLogin(false);
}
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
@@ -916,7 +929,7 @@ runEngineChecks(false);
{systemProxyStatus === 'checking' && 'Checking system proxy configuration…'}
{systemProxyStatus === 'detected' && 'A system proxy was detected. Normal file downloads require an HTTP or HTTPS endpoint; media downloads can use SOCKS.'}
{systemProxyStatus === 'none' && 'No usable system proxy was detected. Downloads will use no proxy.'}
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Downloads will use no proxy until it is available.'}
{systemProxyStatus === 'error' && 'System proxy configuration could not be read. Choose No Proxy or try again when it is available.'}
</p>
)}
</div>
@@ -1107,8 +1120,10 @@ runEngineChecks(false);
return;
}
try {
await invoke('delete_keychain_password', { id: login.id });
settings.removeSiteLogin(login.id);
await runSettingsPersistenceTransaction(async () => {
await invoke('delete_site_login', { id: login.id });
settings.removeSiteLogin(login.id);
});
showToast("Deleted credential", 'success');
} catch (error) {
showToast(`Could not delete credential: ${String(error)}`, 'error');
@@ -1182,10 +1197,12 @@ runEngineChecks(false);
<div className="flex justify-end pt-2">
<button
type="button"
onClick={handleAddLogin}
disabled={isSavingLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
<Plus size={14} /> {isSavingLogin ? 'Saving…' : 'Add Login'}
</button>
</div>
</div>
+5
View File
@@ -55,6 +55,11 @@ type CommandMap = {
set_keychain_password: { args: { id: string; password: string }; result: void };
get_keychain_password: { args: { id: string }; result: string };
delete_keychain_password: { args: { id: string }; result: void };
save_site_login: {
args: { id: string; urlPattern: string; username: string; password: string };
result: void;
};
delete_site_login: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
+32
View File
@@ -201,4 +201,36 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
it('ignores stale active state events after pause but accepts terminal reconciliation', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'paused-race',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'paused',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-state']({ payload: {
id: 'paused-race',
status: 'downloading'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
handlers['download-state']({ payload: {
id: 'paused-race',
status: 'completed'
} });
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
release();
});
});
+11 -1
View File
@@ -78,11 +78,21 @@ const startDownloadListeners = async () => {
}
const status = payload.status as DownloadStatus;
// Prevent race condition: don't transition backwards from terminal state
// Prevent stale lifecycle events from moving a paused row back into an
// active state. A pause request can finish before one already-emitted
// worker event reaches the frontend. Resume paths set the row to queued
// before asking the backend to resume, so an active event arriving while
// the row is still paused cannot represent a new lifecycle.
if ((current.status === 'completed' || current.status === 'failed') &&
status !== current.status) {
return;
}
if (current.status === 'paused' &&
status !== 'paused' &&
status !== 'completed' &&
status !== 'failed') {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
+77
View File
@@ -217,6 +217,15 @@ describe('useDownloadStore', () => {
proxyPort: 8080
} as ReturnType<typeof useSettingsStore.getState>)).toBe('none');
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('system settings unavailable'));
await expect(getProxyArgs({
proxyMode: 'system',
proxyHost: '',
proxyPort: 8080
} as ReturnType<typeof useSettingsStore.getState>)).rejects.toThrow(
'System proxy configuration could not be read: system settings unavailable'
);
expect(await getProxyArgs({
proxyMode: 'custom',
proxyHost: 'http://127.0.0.1',
@@ -224,6 +233,39 @@ describe('useDownloadStore', () => {
} as ReturnType<typeof useSettingsStore.getState>)).toBe('http://127.0.0.1:1080');
});
it('keeps an item queued when system proxy resolution fails closed', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
proxyMode: 'system'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'get_system_proxy') {
throw new Error('system settings unavailable');
}
return undefined;
});
useDownloadStore.setState({
downloads: [{
id: 'system-proxy-blocked',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'queued',
category: 'Other',
dateAdded: ''
}] as any[],
backendRegisteredIds: new Set()
});
await expect(dispatchItem('system-proxy-blocked')).resolves.toBe(false);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'queued',
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
});
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
it('matches site logins by host, wildcard host, path, and full URL patterns', () => {
const settings = {
siteLogins: [
@@ -774,6 +816,41 @@ describe('useDownloadStore', () => {
});
});
it('keeps all startup items retryable when system proxy resolution fails', async () => {
vi.mocked(useSettingsStore.getState).mockReturnValue({
...useSettingsStore.getState(),
proxyMode: 'system'
} as unknown as ReturnType<typeof useSettingsStore.getState>);
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'db_get_all_queues') return [];
if (command === 'db_get_all_downloads') {
return [JSON.stringify({
id: 'startup-proxy-blocked',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status: 'queued',
category: 'Other',
dateAdded: '',
queueId: '00000000-0000-0000-0000-000000000001',
hasBeenDispatched: true
})];
}
if (command === 'get_system_proxy') {
throw new Error('system settings unavailable');
}
return undefined;
});
await useDownloadStore.getState().initDB();
await useDownloadStore.getState().resumePendingDownloads();
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'queued',
lastError: 'System proxy configuration could not be read: system settings unavailable. Choose No Proxy or try again.'
});
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
});
it('keeps accepted startup registrations when pending-order refresh fails', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
+69 -7
View File
@@ -125,6 +125,16 @@ const removeStaleBackendDispatch = async (id: string): Promise<void> => {
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export class SystemProxyResolutionError extends Error {
constructor(reason: string) {
super(`System proxy configuration could not be read: ${reason}. Choose No Proxy or try again.`);
this.name = 'SystemProxyResolutionError';
}
}
const isSystemProxyConfigurationError = (error: unknown): boolean =>
error instanceof SystemProxyResolutionError;
const stripCookieHeaders = (value: string | null | undefined): string =>
(value || '')
.split(/\r?\n/)
@@ -159,7 +169,7 @@ const speedLimitForDispatch = (
return normalizeSpeedLimitForBackend(globalSpeedLimit);
};
export async function dispatchItem(id: string): Promise<boolean> {
export async function dispatchItem(id: string, proxyOverride?: string | null): Promise<boolean> {
await waitForPendingStartupResume();
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
@@ -193,7 +203,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
}
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const proxy = await getProxyArgs(settings);
const proxy = proxyOverride === undefined
? await getProxyArgs(settings)
: proxyOverride;
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
const enqueueItem = {
@@ -252,8 +264,9 @@ export async function dispatchItem(id: string): Promise<boolean> {
await removeStaleBackendDispatch(id);
}
if (lifecycleGeneration !== null && isCurrentDownloadLifecycle(id, lifecycleGeneration)) {
const proxyBlocked = isSystemProxyConfigurationError(e);
useDownloadStore.getState().updateDownload(id, {
status: 'failed',
status: proxyBlocked ? 'queued' : 'failed',
lastError: errorMessage(e)
});
}
@@ -310,8 +323,8 @@ export const getProxyArgs = async (settings: ReturnType<typeof useSettingsStore.
const sysProxy = await invoke('get_system_proxy');
return typeof sysProxy === 'string' && sysProxy ? sysProxy : "none";
} catch (e) {
console.warn("Failed to get system proxy:", e);
return "none";
const reason = e instanceof Error ? e.message : String(e);
throw new SystemProxyResolutionError(reason);
}
}
if (settings.proxyMode === 'custom') {
@@ -1008,6 +1021,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
const needsNewDispatch = runnable.some(item => {
const currentItem = get().downloads.find(download => download.id === item.id);
if (!currentItem) return false;
const backendRegistered = get().backendRegisteredIds.has(item.id);
const backendPending = get().pendingOrder.includes(item.id);
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
return false;
}
return currentItem.status === 'ready' ||
currentItem.status === 'staged' ||
currentItem.status === 'failed' ||
!currentItem.hasBeenDispatched ||
!backendRegistered;
});
let queueProxy: string | null | undefined;
if (needsNewDispatch) {
try {
queueProxy = await getProxyArgs(useSettingsStore.getState());
} catch (error) {
const message = errorMessage(error);
console.error(`Could not safely resolve the proxy for queue ${queueId}:`, error);
const runnableIds = new Set(runnable.map(item => item.id));
set(state => ({
downloads: state.downloads.map(item =>
runnableIds.has(item.id) && item.status !== 'completed'
? { ...item, lastError: message }
: item
)
}));
return [];
}
}
const acceptedIds: string[] = [];
for (const item of runnable) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) break;
@@ -1031,7 +1077,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
!currentItem.hasBeenDispatched ||
!backendRegistered
) {
if (await dispatchItem(item.id)) {
if (await dispatchItem(item.id, queueProxy)) {
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
const afterDispatch = get().downloads.find(download => download.id === item.id);
if (
@@ -1240,6 +1286,22 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
try {
const settings = useSettingsStore.getState();
let proxy: string | null;
try {
proxy = await getProxyArgs(settings);
} catch (error) {
const message = errorMessage(error);
console.error('Could not safely resolve the system proxy during startup resume:', error);
const activeIds = new Set(active.map(item => item.id));
set(state => ({
downloads: state.downloads.map(item =>
activeIds.has(item.id) && item.status === 'queued'
? { ...item, lastError: message }
: item
)
}));
return;
}
const itemsToEnqueue = [];
for (const pendingItem of active) {
const item = get().downloads.find(download => download.id === pendingItem.id);
@@ -1272,7 +1334,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
mirrors: item.mirrors || null,
user_agent: settings.customUserAgent.trim() || null,
max_tries: settings.maxAutomaticRetries,
proxy: await getProxyArgs(settings),
proxy,
format_selector: item.mediaFormatSelector || null,
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
is_media: item.isMedia || false,
+23 -1
View File
@@ -1,5 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { subscribeToSettingsPersistenceErrors, useSettingsStore } from './useSettingsStore';
import {
runSettingsPersistenceTransaction,
subscribeToSettingsPersistenceErrors,
useSettingsStore
} from './useSettingsStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
@@ -68,6 +72,24 @@ describe('useSettingsStore credential-store startup flow', () => {
});
describe('useSettingsStore persistence failures', () => {
it('keeps settings writes queued behind a credential transaction', async () => {
const events: string[] = [];
vi.mocked(ipc.invokeCommand).mockImplementation(async command => {
if (command === 'db_save_settings') events.push('settings-write');
return undefined;
});
await runSettingsPersistenceTransaction(async () => {
events.push('transaction-start');
useSettingsStore.setState({ theme: 'dark' });
events.push('transaction-end');
});
await new Promise(resolve => setTimeout(resolve, 0));
expect(events.slice(0, 2)).toEqual(['transaction-start', 'transaction-end']);
expect(events).toContain('settings-write');
});
it('reports a database save failure and retries the next settings update', async () => {
vi.clearAllMocks();
await new Promise(resolve => setTimeout(resolve, 0));
+17 -8
View File
@@ -19,7 +19,7 @@ import {
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
let settingsSave = Promise.resolve();
let settingsQueue: Promise<void> = Promise.resolve();
const settingsPersistenceErrorListeners = new Set<() => void>();
let settingsPersistenceFailed = false;
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -31,6 +31,16 @@ export const subscribeToSettingsPersistenceErrors = (listener: () => void): (()
return () => settingsPersistenceErrorListeners.delete(listener);
};
const enqueueSettingsTask = <T>(task: () => Promise<T>): Promise<T> => {
const result = settingsQueue.then(task, task);
settingsQueue = result.then(() => undefined, () => undefined);
return result;
};
export const runSettingsPersistenceTransaction = <T>(
operation: () => Promise<T>
): Promise<T> => enqueueSettingsTask(operation);
const notifySettingsPersistenceError = () => {
if (settingsPersistenceFailed) return;
settingsPersistenceFailed = true;
@@ -102,16 +112,15 @@ const tauriStorage: StateStorage = {
},
setItem: async (name: string, value: string): Promise<void> => {
if (name === 'firelink-settings') {
settingsSave = settingsSave
.catch(() => undefined)
.then(() => invoke('db_save_settings', { data: value }))
.then(() => {
await enqueueSettingsTask(async () => {
try {
await invoke('db_save_settings', { data: value });
settingsPersistenceFailed = false;
}, () => {
} catch {
console.error('Failed to save settings to DB');
notifySettingsPersistenceError();
});
await settingsSave;
}
});
}
},
removeItem: async (_name: string): Promise<void> => {