fix(provider): validate current provider ID existence before use

Add get_effective_current_provider() to validate local settings ID
against database, with automatic cleanup and fallback to DB is_current.

This fixes edge cases in multi-device cloud sync scenarios where local
settings may contain stale provider IDs:

- current(): now returns validated effective provider ID
- update(): correctly syncs live config when local ID differs from DB
- delete(): checks both local settings and DB to prevent deletion
- switch(): backfill logic now targets valid provider
- sync_current_to_live(): uses validated ID with auto-fallback
- tray menu: displays correct checkmark on startup

Also fixes test issues:
- Add missing test setup calls (mutex, reset_test_fs, ensure_test_home)
- Correct Gemini security settings path to ~/.gemini/settings.json
This commit is contained in:
Jason
2025-11-27 15:01:24 +08:00
parent 2c90ae3509
commit 964ead5d0d
5 changed files with 84 additions and 75 deletions

View File

@@ -221,16 +221,12 @@ fn create_tray_menu(
let app_type_str = section.app_type.as_str();
let providers = app_state.db.get_all_providers(app_type_str)?;
// 优先从本地 settings 读取当前供应商fallback 到数据库
let current_id = crate::settings::get_current_provider(&section.app_type)
.or_else(|| {
app_state
.db
.get_current_provider(app_type_str)
.ok()
.flatten()
})
.unwrap_or_default();
// 使用有效的当前供应商 ID验证存在性自动清理失效 ID
let current_id = crate::settings::get_effective_current_provider(
&app_state.db,
&section.app_type,
)?
.unwrap_or_default();
let manager = crate::provider::ProviderManager {
providers,

View File

@@ -126,36 +126,24 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
/// Sync current provider to live configuration
///
/// 从本地 settings 获取当前供应商 IDfallback 到数据库的 is_current 字段
/// 这确保了云同步场景下多设备可以独立选择供应商
/// 使用有效的当前供应商 ID验证过存在性
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Priority: local settings > database is_current
let current_id = match crate::settings::get_current_provider(&app_type) {
// Use validated effective current provider
let current_id = match crate::settings::get_effective_current_provider(&state.db, &app_type)?
{
Some(id) => id,
None => {
// Fallback: get from database and update local settings
match state.db.get_current_provider(app_type.as_str())? {
Some(id) => {
// Update local settings for future use
let _ = crate::settings::set_current_provider(&app_type, Some(&id));
id
}
None => continue,
}
}
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
write_live_snapshot(&app_type, provider)?;
} else {
log::warn!(
"无法同步 live 配置: 当前供应商 {} ({}) 未找到",
current_id,
app_type.as_str()
);
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync

View File

@@ -95,17 +95,11 @@ impl ProviderService {
/// Get current provider ID
///
/// 优先从本地 settings 读取当前供应商fallback 到数据库的 is_current 字段
/// 这确保了云同步场景下多设备可以独立选择供应商
/// 使用有效的当前供应商 ID验证过存在性
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段
/// 这确保了云同步场景下多设备可以独立选择供应商,且返回的 ID 一定有效。
pub fn current(state: &AppState, app_type: AppType) -> Result<String, AppError> {
// 优先从本地 settings 读取
if let Some(id) = crate::settings::get_current_provider(&app_type) {
return Ok(id);
}
// Fallback 到数据库的默认供应商
state
.db
.get_current_provider(app_type.as_str())
crate::settings::get_effective_current_provider(&state.db, &app_type)
.map(|opt| opt.unwrap_or_default())
}
@@ -143,9 +137,10 @@ impl ProviderService {
Self::normalize_provider_if_claude(&app_type, &mut provider);
Self::validate_provider_settings(&app_type, &provider)?;
// Check if this is current provider
let current_id = state.db.get_current_provider(app_type.as_str())?;
let is_current = current_id.as_deref() == Some(provider.id.as_str());
// Check if this is current provider (use effective current, not just DB)
let effective_current =
crate::settings::get_effective_current_provider(&state.db, &app_type)?;
let is_current = effective_current.as_deref() == Some(provider.id.as_str());
// Save to database
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -160,13 +155,19 @@ impl ProviderService {
}
/// Delete a provider
///
/// 同时检查本地 settings 和数据库的当前供应商,防止删除任一端正在使用的供应商。
pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> {
let current = state.db.get_current_provider(app_type.as_str())?;
if current.as_deref() == Some(id) {
// Check both local settings and database
let local_current = crate::settings::get_current_provider(&app_type);
let db_current = state.db.get_current_provider(app_type.as_str())?;
if local_current.as_deref() == Some(id) || db_current.as_deref() == Some(id) {
return Err(AppError::Message(
"无法删除当前正在使用的供应商".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)
}
@@ -187,9 +188,8 @@ impl ProviderService {
.ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?;
// Backfill: Backfill current live config to current provider
// Use local settings first, fallback to database
let current_id = crate::settings::get_current_provider(&app_type)
.or_else(|| state.db.get_current_provider(app_type.as_str()).ok().flatten());
// Use effective current provider (validated existence) to ensure backfill targets valid provider
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
if let Some(current_id) = current_id {
if current_id != id {

View File

@@ -260,3 +260,38 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
update_settings(settings)
}
/// 获取有效的当前供应商 ID验证存在性
///
/// 逻辑:
/// 1. 从本地 settings 读取当前供应商 ID
/// 2. 验证该 ID 在数据库中存在
/// 3. 如果不存在则清理本地 settingsfallback 到数据库的 is_current
///
/// 这确保了返回的 ID 一定是有效的(在数据库中存在)。
/// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。
pub fn get_effective_current_provider(
db: &crate::database::Database,
app_type: &AppType,
) -> Result<Option<String>, AppError> {
// 1. 从本地 settings 读取
if let Some(local_id) = get_current_provider(app_type) {
// 2. 验证该 ID 在数据库中存在
let providers = db.get_all_providers(app_type.as_str())?;
if providers.contains_key(&local_id) {
// 存在,直接返回
return Ok(Some(local_id));
}
// 3. 不存在,清理本地 settings
log::warn!(
"本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库",
local_id,
app_type.as_str()
);
let _ = set_current_provider(app_type, None);
}
// Fallback 到数据库的 is_current
db.get_current_provider(app_type.as_str())
}

View File

@@ -185,15 +185,16 @@ fn switch_packycode_gemini_updates_security_selected_type() {
ProviderService::switch(&state, AppType::Gemini, "packy-gemini")
.expect("switching to PackyCode Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -239,15 +240,16 @@ fn packycode_partner_meta_triggers_security_flag_even_without_keywords() {
ProviderService::switch(&state, AppType::Gemini, "packy-meta")
.expect("switching to partner meta provider should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let settings_path = home.join(".gemini").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
"Gemini settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let raw = std::fs::read_to_string(&settings_path).expect("read gemini settings.json");
let value: serde_json::Value =
serde_json::from_str(&raw).expect("parse settings.json after switch");
serde_json::from_str(&raw).expect("parse gemini settings.json after switch");
assert_eq!(
value
@@ -292,23 +294,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
ProviderService::switch(&state, AppType::Gemini, "google-official")
.expect("switching to Google official Gemini should succeed");
let settings_path = home.join(".cc-switch").join("settings.json");
assert!(
settings_path.exists(),
"settings.json should exist at {}",
settings_path.display()
);
let raw = std::fs::read_to_string(&settings_path).expect("read settings.json");
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json");
assert_eq!(
value
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Google official Gemini should set oauth-personal selectedType in app settings"
);
// Gemini security settings are written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
let gemini_settings = home.join(".gemini").join("settings.json");
assert!(
gemini_settings.exists(),
@@ -324,7 +310,7 @@ fn switch_google_official_gemini_sets_oauth_security() {
.pointer("/security/auth/selectedType")
.and_then(|v| v.as_str()),
Some("oauth-personal"),
"Gemini settings json should also reflect oauth-personal"
"Gemini settings json should reflect oauth-personal for Google Official"
);
}
@@ -593,6 +579,10 @@ fn provider_service_delete_claude_removes_provider_files() {
#[test]
fn provider_service_delete_current_provider_returns_error() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let mut config = MultiAppConfig::default();
{
let manager = config