From 380d03cb97f9a6ebb0abd039965baed4fae38e81 Mon Sep 17 00:00:00 2001 From: Rusty Raven Date: Wed, 9 Sep 2026 11:34:43 +0000 Subject: [PATCH] fix(macos): discard a core cache the current user cannot write (#7883) * fix(macos): discard a core cache the current user cannot write * docs(changelog): add the unwritable core cache fix * docs: update changelog to include Linux in cache.db ownership fix --------- Co-authored-by: oomeow --- Changelog.md | 1 + src-tauri/src/core/manager/state.rs | 103 ++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/Changelog.md b/Changelog.md index 54d256b48..1b7d03d21 100644 --- a/Changelog.md +++ b/Changelog.md @@ -18,6 +18,7 @@ 修复 i18n 清理脚本因导入方式错误而无法运行的问题 修复 Linux 桌面环境下主题切换时应用外观不跟随的问题 修复 Gemini 测试正确性 +修复 macOS/Linux 用户模式下旧版服务遗留的 cache.db 属主为 root 且当前用户不可写,导致无法记住上次选择节点的问题 修复代理组节点 `PASS-RULE` 被过滤丢失的问题
diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index c90831867..f39e872ec 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -108,6 +108,8 @@ impl CoreManager { let app_handle = handle::Handle::app_handle(); let clash_core = Config::verge().await.latest_arc().get_valid_clash_core(); let config_dir = dirs::app_home_dir()?; + #[cfg(unix)] + discard_unwritable_core_cache(&config_dir); #[cfg(unix)] let previous_mask = unsafe { tauri_plugin_clash_verge_sysinfo::libc::umask(0o077) }; @@ -322,6 +324,107 @@ impl CoreManager { } } +/// Drops a `cache.db` the current user cannot write before handing the directory to the core. +/// +/// mihomo keeps `profile.store-selected` in `cache.db` inside its data directory. Service builds +/// before the runtime staging rework ran the core as root against this same directory without a +/// umask, leaving the file as `root:staff 0644`: still readable, so the core loads stale +/// selections, but never writable again, so it silently stops recording new ones. Nothing +/// repairs it either, because the service-side cleanup only runs inside the service. Removing it +/// lets the core recreate the cache under the current user; the fake-ip leases and frozen +/// selections that go with it could not be updated anyway. +#[cfg(unix)] +fn discard_unwritable_core_cache(config_dir: &Path) { + let cache = config_dir.join("cache.db"); + // Appending neither creates nor truncates, so this only asks whether a write would be allowed. + match std::fs::OpenOptions::new().append(true).open(&cache) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { + // Unlinking is governed by the directory, which the current user owns. + match std::fs::remove_file(&cache) { + Ok(()) => logging!( + info, + Type::Core, + "Discarded a core cache the current user cannot write: {}", + cache.display() + ), + Err(error) => logging!( + warn, + Type::Core, + "Failed to discard the unwritable core cache {}: {error}", + cache.display() + ), + } + } + Err(error) => logging!( + warn, + Type::Core, + "Failed to probe the core cache {}: {error}", + cache.display() + ), + } +} + +#[cfg(all(test, unix))] +mod core_cache_tests { + use super::discard_unwritable_core_cache; + use std::os::unix::fs::PermissionsExt as _; + + fn scratch(name: &str) -> anyhow::Result { + let root = std::env::temp_dir().join(format!("clash-verge-core-cache-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root)?; + Ok(root) + } + + #[test] + fn an_unwritable_cache_is_discarded() -> anyhow::Result<()> { + // root ignores the permission bits, so the probe cannot fail there. + if unsafe { tauri_plugin_clash_verge_sysinfo::libc::geteuid() } == 0 { + return Ok(()); + } + let root = scratch("unwritable")?; + let cache = root.join("cache.db"); + std::fs::write(&cache, b"stale")?; + std::fs::set_permissions(&cache, std::fs::Permissions::from_mode(0o444))?; + + discard_unwritable_core_cache(&root); + + assert!(!cache.exists(), "an unwritable cache must not be handed to the core"); + let _ = std::fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn a_writable_cache_is_kept() -> anyhow::Result<()> { + let root = scratch("writable")?; + let cache = root.join("cache.db"); + std::fs::write(&cache, b"live")?; + + discard_unwritable_core_cache(&root); + + assert_eq!( + std::fs::read(&cache)?, + b"live", + "a writable cache carries the stored selections and must survive" + ); + let _ = std::fs::remove_dir_all(&root); + Ok(()) + } + + #[test] + fn a_missing_cache_is_not_an_error() -> anyhow::Result<()> { + let root = scratch("missing")?; + + discard_unwritable_core_cache(&root); + + assert!(!root.join("cache.db").exists()); + let _ = std::fs::remove_dir_all(&root); + Ok(()) + } +} + #[cfg(test)] mod readiness_tests { use super::{claim_core_readiness_generation, poll_sidecar_readiness, should_clear_terminated_sidecar};