fix(settings): harden settings and diagnostic controls

This commit is contained in:
NimBold
2026-07-17 00:39:26 +03:30
parent 5144ecd39e
commit 6ef911919d
10 changed files with 226 additions and 18 deletions
+17
View File
@@ -5868,6 +5868,7 @@ async fn log_files(app_handle: &tauri::AppHandle) -> Result<Vec<std::path::PathB
pub(crate) fn redact_sensitive_text(line: &str) -> String {
use std::sync::OnceLock;
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static QUOTED_SECRET: OnceLock<regex::Regex> = OnceLock::new();
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
static USERINFO: OnceLock<regex::Regex> = 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(
+15 -1
View File
@@ -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<string>();
@@ -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,
+16 -2
View File
@@ -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<LogEntry['level'] | 'All'>('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<HTMLDivElement>(null);
const liveBatchRef = useRef<LogEntry[]>([]);
const liveFrameRef = useRef<number | null>(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 = [];
+39 -4
View File
@@ -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<string, EngineStatusItem>();
const engineStatusInFlight = new Map<string, Promise<EngineStatusItem>>();
@@ -276,8 +279,9 @@ const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
const engineRunId = useRef(0);
const [appVersion, setAppVersion] = useState('Unknown');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
const [appVersion, setAppVersion] = useState('Unknown');
const [extensionServerPort, setExtensionServerPort] = useState<number | null>(null);
const [systemProxyStatus, setSystemProxyStatus] = useState<SystemProxyStatus>('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);
<p className="settings-group-footer">
{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.')}
</p>
{settings.proxyMode === 'system' && (
<p className="settings-group-footer" role="status">
{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.'}
</p>
)}
</div>
<h2 className="settings-section-title">Identity</h2>
+3
View File
@@ -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',
+19 -1
View File
@@ -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<typeof useSettingsStore.getState>) => {
+24 -1
View File
@@ -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();
});
});
+26 -2
View File
@@ -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<void> => {
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;
}
+24
View File
@@ -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] <HOME>/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')],
+43 -7
View File
@@ -19,24 +19,60 @@ const LIVE_LEVELS: Record<number, LogLevel> = {
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('<HOME>');
}
if (normalizedHomeWithForwardSlashes && normalizedHomeWithForwardSlashes !== normalizedHome) {
redacted = redacted.split(normalizedHomeWithForwardSlashes).join('<HOME>');
}
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}`
};
};