From 0a301a497c3011c737fe706f0e7ed812fed5fa97 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 23 Mar 2026 09:54:05 +0800 Subject: [PATCH] fix: prevent WebDAV password from being silently cleared by unrelated saves Two components (ProviderList, UsageScriptModal) directly spread the full settings object from the query into settingsApi.save(), which includes webdavSync with an empty password (cleared by get_settings_for_frontend for security). The backend merge_settings_for_save only preserved existing WebDAV config when the incoming field was None, not when it was present with an empty password. Frontend fix: strip webdavSync before saving in both components, matching the pattern already used by useSettings hook. Backend defense-in-depth: merge_settings_for_save now backfills the existing password when the incoming one is empty, preventing future regressions from similar oversights. --- src-tauri/src/commands/settings.rs | 76 ++++++++++++++++++++++- src/components/UsageScriptModal.tsx | 3 +- src/components/providers/ProviderList.tsx | 3 +- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 23304ebbc..3d07e9514 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -6,8 +6,20 @@ fn merge_settings_for_save( mut incoming: crate::settings::AppSettings, existing: &crate::settings::AppSettings, ) -> crate::settings::AppSettings { - if incoming.webdav_sync.is_none() { - incoming.webdav_sync = existing.webdav_sync.clone(); + match (&mut incoming.webdav_sync, &existing.webdav_sync) { + // incoming 没有 webdav → 保留现有 + (None, _) => { + incoming.webdav_sync = existing.webdav_sync.clone(); + } + // incoming 有 webdav 但密码为空,且现有有密码 → 填回现有密码 + // (get_settings_for_frontend 总是清空密码,所以通过 save_settings + // 传入的空密码意味着"保持现有"而非"用户主动清空") + (Some(incoming_sync), Some(existing_sync)) + if incoming_sync.password.is_empty() && !existing_sync.password.is_empty() => + { + incoming_sync.password = existing_sync.password.clone(); + } + _ => {} } incoming } @@ -116,6 +128,66 @@ mod tests { Some("https://dav.new.example.com") ); } + + /// Regression test: frontend always receives empty password from + /// get_settings_for_frontend(). If a component accidentally spreads + /// the full settings object into save_settings, the empty password + /// must NOT overwrite the existing one. + #[test] + fn save_settings_should_preserve_password_when_incoming_has_empty_password() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "secret".to_string(), + ..WebDavSyncSettings::default() + }); + + // Simulate frontend sending settings with cleared password + let mut incoming = AppSettings::default(); + incoming.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "".to_string(), + ..WebDavSyncSettings::default() + }); + + let merged = merge_settings_for_save(incoming, &existing); + + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.password.as_str()), + Some("secret"), + "empty password from frontend must not overwrite existing password" + ); + } + + /// When both incoming and existing have no password, merge should + /// work without panicking and keep the empty state. + #[test] + fn save_settings_should_handle_both_empty_passwords() { + let mut existing = AppSettings::default(); + existing.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "".to_string(), + ..WebDavSyncSettings::default() + }); + + let mut incoming = AppSettings::default(); + incoming.webdav_sync = Some(WebDavSyncSettings { + base_url: "https://dav.example.com".to_string(), + username: "alice".to_string(), + password: "".to_string(), + ..WebDavSyncSettings::default() + }); + + let merged = merge_settings_for_save(incoming, &existing); + + assert_eq!( + merged.webdav_sync.as_ref().map(|v| v.password.as_str()), + Some("") + ); + } } /// 获取开机自启状态 diff --git a/src/components/UsageScriptModal.tsx b/src/components/UsageScriptModal.tsx index c248b52c7..87692cb37 100644 --- a/src/components/UsageScriptModal.tsx +++ b/src/components/UsageScriptModal.tsx @@ -267,7 +267,8 @@ const UsageScriptModal: React.FC = ({ setShowUsageConfirm(false); try { if (settingsData) { - await settingsApi.save({ ...settingsData, usageConfirmed: true }); + const { webdavSync: _, ...rest } = settingsData; + await settingsApi.save({ ...rest, usageConfirmed: true }); await queryClient.invalidateQueries({ queryKey: ["settings"] }); } } catch (error) { diff --git a/src/components/providers/ProviderList.tsx b/src/components/providers/ProviderList.tsx index 6a283a0b2..73818b941 100644 --- a/src/components/providers/ProviderList.tsx +++ b/src/components/providers/ProviderList.tsx @@ -204,7 +204,8 @@ export function ProviderList({ setShowStreamCheckConfirm(false); try { if (settings) { - await settingsApi.save({ ...settings, streamCheckConfirmed: true }); + const { webdavSync: _, ...rest } = settings; + await settingsApi.save({ ...rest, streamCheckConfirmed: true }); await queryClient.invalidateQueries({ queryKey: ["settings"] }); } } catch (error) {