diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 44f35d5..c2c104c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5868,6 +5868,7 @@ async fn log_files(app_handle: &tauri::AppHandle) -> Result String { use std::sync::OnceLock; static SECRET: OnceLock = OnceLock::new(); + static QUOTED_SECRET: OnceLock = OnceLock::new(); static HEADER: OnceLock = OnceLock::new(); static QUERY: OnceLock = OnceLock::new(); static USERINFO: OnceLock = OnceLock::new(); @@ -5878,6 +5879,12 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { ) .expect("valid secret redaction regex") }); + let quoted_secret = QUOTED_SECRET.get_or_init(|| { + regex::Regex::new( + r#"(?i)(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']"#, + ) + .expect("valid quoted secret redaction regex") + }); let header = HEADER.get_or_init(|| { regex::Regex::new( r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+", @@ -5897,6 +5904,7 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String { .expect("valid URL fragment redaction regex") }); let redacted = header.replace_all(line, "$1: [redacted]"); + let redacted = quoted_secret.replace_all(&redacted, "$1$2$3$4[redacted]"); let redacted = secret.replace_all(&redacted, "$1=[redacted]"); let redacted = userinfo.replace_all(&redacted, "$1[redacted]@"); let redacted = query.replace_all(&redacted, "$1?[redacted]"); @@ -7092,6 +7100,15 @@ mod tests { assert!(redacted.contains("http://[redacted]@example.com/file#[redacted]")); } + #[test] + fn redacts_quoted_json_credentials() { + let line = r#"{"api_key":"json-secret","cookie":"session=secret"}"#; + let redacted = redact_log_line(line); + assert!(!redacted.contains("json-secret")); + assert!(!redacted.contains("session=secret")); + assert!(redacted.contains("[redacted]")); + } + #[test] fn collects_primary_url_and_unique_mirrors_in_order() { let uris = collect_download_uris( diff --git a/src/App.tsx b/src/App.tsx index e4e9be6..4461bef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,7 @@ import { readClipboardDownloadUrls } from './utils/clipboard'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; -import { useSettingsStore } from "./store/useSettingsStore"; +import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; @@ -27,6 +27,7 @@ import { getKeychainConsentVersion, getKeychainStartupDecision } from './utils/k import { getVersion } from '@tauri-apps/api/app'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; +import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls'; let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); @@ -144,6 +145,14 @@ function App() { // briefly rendering underneath native or custom window controls. const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os); + useEffect(() => subscribeToSettingsPersistenceErrors(() => { + addToast({ + message: 'Could not save settings. Check storage permissions and try again.', + variant: 'error', + isActionable: true + }); + }), [addToast]); + const acknowledgePairingTokenChange = () => { invoke('acknowledge_pairing_token_change').catch(error => { console.error('Failed to acknowledge pairing token migration notice:', error); @@ -515,6 +524,9 @@ function App() { invoke('check_for_updates') .then(result => { if (result.type !== 'UpdateAvailable') return; + if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) { + throw new Error('The update check returned an untrusted release URL.'); + } addToast({ variant: 'info', isActionable: true, @@ -657,6 +669,7 @@ function App() { }, [addToast, clearPendingPostActionTimer, coreReady]); useEffect(() => { + if (!coreReady) return; if (!schedulerRunning) return; if (schedulerActiveDownloadIds.length === 0) return; clearPendingPostActionTimer(); @@ -687,6 +700,7 @@ function App() { }, [ addToast, clearPendingPostActionTimer, + coreReady, downloads, schedulePostQueueAction, schedulerRunning, diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index 2862e5f..ad38c6e 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { invokeCommand as invoke } from '../ipc'; import { save } from '@tauri-apps/plugin-dialog'; +import { homeDir } from '@tauri-apps/api/path'; import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger'; import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react'; import { WindowDragRegion } from './WindowDragRegion'; @@ -24,6 +25,7 @@ export default function LogsView() { const [levelFilter, setLevelFilter] = useState('All'); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); const [pageVisible, setPageVisible] = useState(() => document.visibilityState !== 'hidden'); + const homeDirectoryRef = useRef(''); const scrollRef = useRef(null); const liveBatchRef = useRef([]); const liveFrameRef = useRef(null); @@ -33,6 +35,18 @@ export default function LogsView() { const [isClearing, setIsClearing] = useState(false); const [isToggling, setIsToggling] = useState(false); + useEffect(() => { + let active = true; + void homeDir() + .then(directory => { + if (active) homeDirectoryRef.current = directory; + }) + .catch(() => undefined); + return () => { + active = false; + }; + }, []); + useEffect(() => { const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden'); document.addEventListener('visibilitychange', handleVisibilityChange); @@ -80,7 +94,7 @@ export default function LogsView() { if (logsEnabled) { unlistenPromise = attachLogger((log) => { if (!active) return; - scheduleLiveEntry(liveLogEntry(log.level, log.message)); + scheduleLiveEntry(liveLogEntry(log.level, log.message, new Date(), homeDirectoryRef.current)); }); await unlistenPromise; if (!active) return; @@ -93,7 +107,7 @@ export default function LogsView() { const lines = await invoke('read_logs', { limit: MAX_LOG_LINES }); if (!active) return; - const snapshot = lines.map(persistedLogEntry); + const snapshot = lines.map(line => persistedLogEntry(line, homeDirectoryRef.current)); initialized = true; if (initGeneration !== clearGenerationRef.current) { pendingLiveEntries = []; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 990217d..6cefe44 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -29,6 +29,7 @@ import { } from '../utils/downloadLocations'; import { usePlatformInfo } from '../utils/platform'; import { isTrustedFirelinkReleaseUrl } from '../utils/releaseUrls'; +import { normalizeCustomProxy } from '../store/useDownloadStore'; const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [ { type: 'downloads', label: 'Downloads', icon: Download }, @@ -76,6 +77,8 @@ type ManualUpdateStatus = | { type: 'update-available'; version: string; releaseUrl: string } | { type: 'error'; message: string }; +type SystemProxyStatus = 'idle' | 'checking' | 'detected' | 'none' | 'error'; + const engineStatusCache = new Map(); const engineStatusInFlight = new Map>(); @@ -276,8 +279,9 @@ const [engineStatus, setEngineStatus] = useState(null const [expandedEngine, setExpandedEngine] = useState(null); const [isRecheckingEngines, setIsRecheckingEngines] = useState(false); const engineRunId = useRef(0); -const [appVersion, setAppVersion] = useState('Unknown'); -const [extensionServerPort, setExtensionServerPort] = useState(null); + const [appVersion, setAppVersion] = useState('Unknown'); + const [extensionServerPort, setExtensionServerPort] = useState(null); + const [systemProxyStatus, setSystemProxyStatus] = useState('idle'); // Local state for adding site login const [loginPattern, setLoginPattern] = useState(''); @@ -320,6 +324,27 @@ useEffect(() => { }; }, [settings.activeView, activeTab]); +useEffect(() => { + if (settings.activeView !== 'settings' || activeTab !== 'network' || settings.proxyMode !== 'system') { + setSystemProxyStatus('idle'); + return; + } + + let active = true; + setSystemProxyStatus('checking'); + invoke('get_system_proxy') + .then(proxy => { + if (active) setSystemProxyStatus(typeof proxy === 'string' && proxy.trim() ? 'detected' : 'none'); + }) + .catch(() => { + if (active) setSystemProxyStatus('error'); + }); + + return () => { + active = false; + }; +}, [settings.activeView, settings.proxyMode, activeTab]); + const runEngineChecks = useCallback((force = false) => { const runId = ++engineRunId.current; const cached = engineChecks @@ -880,10 +905,20 @@ runEngineChecks(false);

{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'} {settings.proxyMode === 'system' && `Downloads use the detected ${platform.os === 'macos' ? 'macOS' : platform.os === 'windows' ? 'Windows' : 'desktop'} system proxy. Normal file downloads require an HTTP or HTTPS proxy endpoint; media downloads can use SOCKS.`} - {settings.proxyMode === 'custom' && (settings.proxyHost + {settings.proxyMode === 'custom' && (normalizeCustomProxy(settings.proxyHost, settings.proxyPort) ? 'Downloads use the configured HTTP proxy endpoint for metadata and download engines.' - : 'Enter a proxy host and port to enable the custom proxy.')} + : settings.proxyHost + ? 'Enter a valid HTTP proxy host and port to enable the custom proxy.' + : 'Enter a proxy host and port to enable the custom proxy.')}

+ {settings.proxyMode === 'system' && ( +

+ {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.'} +

+ )}

Identity

diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index ad8bb73..5dbacd1 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -200,6 +200,9 @@ describe('useDownloadStore', () => { expect(normalizeCustomProxy(' socks5://127.0.0.1 ', 1080)).toBeNull(); expect(normalizeCustomProxy('https://proxy.local', 8443)).toBeNull(); expect(normalizeCustomProxy('127.0.0.1', NaN)).toBeNull(); + expect(normalizeCustomProxy('127.0.0.1:9000', 8080)).toBeNull(); + expect(normalizeCustomProxy('127.0.0.1/path', 8080)).toBeNull(); + expect(normalizeCustomProxy('[::1]', 8080)).toBe('http://[::1]:8080'); expect(await getProxyArgs({ proxyMode: 'none', diff --git a/src/store/useDownloadStore.ts b/src/store/useDownloadStore.ts index 6313c7d..9e0c698 100644 --- a/src/store/useDownloadStore.ts +++ b/src/store/useDownloadStore.ts @@ -276,6 +276,7 @@ export const normalizeCustomProxy = (host: string, port: number): string | null try { const parsed = new URL(trimmedHost); if (parsed.protocol !== 'http:') return null; + if (!parsed.hostname) return null; if (!parsed.port) parsed.port = String(normalizedPort); return parsed.toString().replace(/\/$/, ''); } catch { @@ -283,7 +284,24 @@ export const normalizeCustomProxy = (host: string, port: number): string | null } } - return `http://${trimmedHost}:${normalizedPort}`; + try { + const parsed = new URL(`http://${trimmedHost}:${normalizedPort}`); + if ( + !parsed.hostname + || parsed.username + || parsed.password + || parsed.pathname !== '/' + || parsed.search + || parsed.hash + || (parsed.port && Number(parsed.port) !== normalizedPort) + || (!parsed.port && normalizedPort !== 80) + ) { + return null; + } + return `http://${trimmedHost}:${normalizedPort}`; + } catch { + return null; + } }; export const getProxyArgs = async (settings: ReturnType) => { diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 359a217..3e1607a 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useSettingsStore } from './useSettingsStore'; +import { subscribeToSettingsPersistenceErrors, useSettingsStore } from './useSettingsStore'; import * as ipc from '../ipc'; vi.mock('../ipc', () => ({ @@ -66,3 +66,26 @@ describe('useSettingsStore credential-store startup flow', () => { expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true); }); }); + +describe('useSettingsStore persistence failures', () => { + it('reports a database save failure and retries the next settings update', async () => { + vi.clearAllMocks(); + await new Promise(resolve => setTimeout(resolve, 0)); + + const onPersistenceError = vi.fn(); + const unsubscribe = subscribeToSettingsPersistenceErrors(onPersistenceError); + vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('database unavailable')); + + useSettingsStore.setState({ theme: 'dark' }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(onPersistenceError).toHaveBeenCalledTimes(1); + + vi.mocked(ipc.invokeCommand).mockResolvedValueOnce(undefined); + useSettingsStore.setState({ theme: 'light' }); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(onPersistenceError).toHaveBeenCalledTimes(1); + unsubscribe(); + }); +}); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index b370ccb..76deea3 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -20,9 +20,29 @@ import { import { normalizeSpeedLimitForBackend } from '../utils/downloads'; let settingsSave = Promise.resolve(); +const settingsPersistenceErrorListeners = new Set<() => void>(); +let settingsPersistenceFailed = false; const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10]; +export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => { + settingsPersistenceErrorListeners.add(listener); + if (settingsPersistenceFailed) listener(); + return () => settingsPersistenceErrorListeners.delete(listener); +}; + +const notifySettingsPersistenceError = () => { + if (settingsPersistenceFailed) return; + settingsPersistenceFailed = true; + for (const listener of settingsPersistenceErrorListeners) { + try { + listener(); + } catch (error) { + console.error('Settings persistence error listener failed', error); + } + } +}; + const THEME_VALUES = ['system', 'light', 'dark', 'dracula', 'nord'] as const; const APP_FONT_SIZE_VALUES = ['small', 'standard', 'large'] as const; const LIST_ROW_DENSITY_VALUES = ['compact', 'standard', 'relaxed'] as const; @@ -83,9 +103,13 @@ const tauriStorage: StateStorage = { setItem: async (name: string, value: string): Promise => { if (name === 'firelink-settings') { settingsSave = settingsSave + .catch(() => undefined) .then(() => invoke('db_save_settings', { data: value })) - .catch(e => { - console.error("Failed to save settings to DB", e); + .then(() => { + settingsPersistenceFailed = false; + }, () => { + console.error('Failed to save settings to DB'); + notifySettingsPersistenceError(); }); await settingsSave; } diff --git a/src/utils/logEntries.test.ts b/src/utils/logEntries.test.ts index 6ff5145..7210a28 100644 --- a/src/utils/logEntries.test.ts +++ b/src/utils/logEntries.test.ts @@ -5,6 +5,7 @@ import { mergeLogSnapshotAndLiveEntries, persistedLogEntry, pushBoundedLogEntry, + redactLogText, type LogEntry } from './logEntries'; @@ -30,6 +31,29 @@ describe('log entry streaming', () => { }); }); + it('redacts live secrets, signed URL components, and the home path', () => { + const message = 'Cookie: session=secret; https://example.com/file?token=signed https://example.com/file#fragment /Users/nima/Downloads/file'; + const redacted = redactLogText(message, '/Users/nima'); + + expect(redacted).toBe('Cookie: [redacted]; https://example.com/file?[redacted] https://example.com/file#[redacted] /Downloads/file'); + expect(redacted).not.toContain('secret'); + expect(redacted).not.toContain('signed'); + expect(redacted).not.toContain('/Users/nima'); + }); + + it('redacts live content before it is formatted for display', () => { + expect(liveLogEntry(3, 'Authorization: Bearer secret').message).toContain('Authorization: [redacted]'); + expect(liveLogEntry(3, 'Authorization: Bearer secret').message).not.toContain('secret'); + }); + + it('redacts persisted content and quoted credential fields', () => { + const persisted = persistedLogEntry('{"api_key":"json-secret","path":"/Users/nima/file"}', '/Users/nima'); + + expect(persisted.message).toContain('api_key'); + expect(persisted.message).not.toContain('json-secret'); + expect(persisted.message).not.toContain('/Users/nima'); + }); + it('merges only the ordered snapshot-to-stream overlap', () => { expect(mergeLogSnapshotAndLiveEntries( [entry('one'), entry('repeat'), entry('three')], diff --git a/src/utils/logEntries.ts b/src/utils/logEntries.ts index c3b3af7..c65a3ae 100644 --- a/src/utils/logEntries.ts +++ b/src/utils/logEntries.ts @@ -19,24 +19,60 @@ const LIVE_LEVELS: Record = { const levelFromMessage = (message: string): LogLevel | undefined => LEVEL_NAMES.find(level => message.includes(`[${level.toUpperCase()}]`)); -export const persistedLogEntry = (message: string): LogEntry => ({ +export const persistedLogEntry = (message: string, homePath = ''): LogEntry => ({ level: levelFromMessage(message) || 'Debug', - message + message: redactLogText(message, homePath) }); +export const redactLogText = (message: string, homePath = ''): string => { + const normalizedHome = homePath.trim(); + const normalizedHomeWithForwardSlashes = normalizedHome.replace(/\\/g, '/'); + let redacted = message; + + if (normalizedHome) { + redacted = redacted.split(normalizedHome).join(''); + } + if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) { + redacted = redacted.split(normalizedHomeWithForwardSlashes).join(''); + } + + redacted = redacted.replace( + /(["'])(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(["'])(\s*[:=]\s*)["'][^"\r\n,;]*["']/gi, + '$1$2$3$4[redacted]' + ); + redacted = redacted.replace( + /([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^@\s/?#]+@/g, + '$1[redacted]@' + ); + redacted = redacted.replace( + /([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?]+)\?[^\s]+/g, + '$1?[redacted]' + ); + redacted = redacted.replace( + /([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s?#]+)#\S+/g, + '$1#[redacted]' + ); + return redacted.replace( + /(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)(\s*)([:=])(\s*)([^\r\n,;]+)/gi, + '$1$2$3$4[redacted]' + ); +}; + export const liveLogEntry = ( numericLevel: number, message: string, - now: Date = new Date() + now: Date = new Date(), + homePath = '' ): LogEntry => { - const level = levelFromMessage(message) || LIVE_LEVELS[numericLevel] || 'Debug'; - const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(message); + const redactedMessage = redactLogText(message, homePath); + const level = levelFromMessage(redactedMessage) || LIVE_LEVELS[numericLevel] || 'Debug'; + const alreadyFormatted = /^\[\d{4}-\d{2}-\d{2}\]\[\d{2}:\d{2}:\d{2}\]\[(TRACE|DEBUG|INFO|WARN|ERROR)\]/.test(redactedMessage); return { level, message: alreadyFormatted - ? message - : `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${message}` + ? redactedMessage + : `[${now.toISOString().replace('T', ' ').substring(0, 19)}] [${level.toUpperCase()}] ${redactedMessage}` }; };