mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 18:33:39 +00:00
fix: harden audited persistence and download paths
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user