From a7b8169b4ca3ac6dbcfcf501189735e7cee05e01 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Sat, 30 May 2026 18:27:01 +0800 Subject: [PATCH 01/17] fix: restrict the permission of database --- src-tauri/src/config.rs | 171 +++++++++++++++++++++++++++++++ src-tauri/src/database/backup.rs | 18 ++++ src-tauri/src/database/mod.rs | 24 +++++ src-tauri/src/database/tests.rs | 27 +++++ src-tauri/src/lib.rs | 1 + src-tauri/src/main.rs | 1 + 6 files changed, 242 insertions(+) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index baefeddc..ffc3f601 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -94,11 +94,137 @@ pub fn get_app_config_dir() -> PathBuf { home_dir().expect("无法获取用户主目录").join(".cc-switch") } +/// 校验 CC_SWITCH_CONFIG_DIR 是否为安全的应用专属目录 +/// +/// 拒绝系统关键目录(如 `/`、`/etc`、`/usr` 等),防止下游权限操作破坏系统。 +/// 未设置环境变量时默认路径 `~/.cc-switch` 始终安全,直接放行。 +pub fn validate_config_dir() -> Result<(), AppError> { + let Some(raw) = env::var_os("CC_SWITCH_CONFIG_DIR") else { + return Ok(()); + }; + let path = PathBuf::from(&raw); + if path.as_os_str().is_empty() || path.to_string_lossy().trim().is_empty() { + return Ok(()); + } + + // 检查原始路径和 canonicalize 后的路径(macOS 下 /etc -> /private/etc) + let resolved = path.canonicalize().unwrap_or_else(|_| path.clone()); + + if is_system_dir(&path) || is_system_dir(&resolved) { + return Err(AppError::InvalidInput(format!( + "CC_SWITCH_CONFIG_DIR 不能设置为系统目录: {}(解析后: {})", + path.display(), + resolved.display() + ))); + } + + Ok(()) +} + +/// 判断路径是否为系统关键目录(不应被应用修改权限) +fn is_system_dir(path: &Path) -> bool { + // 根目录 + if path == Path::new("/") { + return true; + } + + // 一级系统目录 + #[cfg(unix)] + { + const SYSTEM_DIRS: &[&str] = &[ + "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/opt", + "/proc", "/root", "/run", "/sbin", "/sys", "/tmp", "/usr", "/var", + ]; + if SYSTEM_DIRS.iter().any(|&sys| path == Path::new(sys)) { + return true; + } + } + + // macOS 特有(含 /private/* 变体,/etc、/tmp、/var 在 macOS 上是这些的符号链接) + #[cfg(target_os = "macos")] + { + const MACOS_SYSTEM_DIRS: &[&str] = &[ + "/Applications", + "/Library", + "/System", + "/Volumes", + "/private", + "/private/etc", + "/private/tmp", + "/private/var", + ]; + if MACOS_SYSTEM_DIRS + .iter() + .any(|&sys| path == Path::new(sys)) + { + return true; + } + } + + // Windows: 盘符根目录(如 C:\) + #[cfg(windows)] + { + if path.parent().is_none() && path.drive().is_some() { + return true; + } + } + + false +} + /// 获取应用配置文件路径 pub fn get_app_config_path() -> PathBuf { get_app_config_dir().join("config.json") } +/// 将目录权限收紧为仅所有者可访问(Unix: 0o700) +#[cfg(unix)] +pub fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(path)?; + if !meta.is_dir() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a directory", + )); + } + let mut perms = meta.permissions(); + if perms.mode() & 0o777 != 0o700 { + perms.set_mode(0o700); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn restrict_dir_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// 将文件权限收紧为仅所有者可读写(Unix: 0o600) +#[cfg(unix)] +pub fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let meta = fs::metadata(path)?; + if !meta.is_file() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is not a regular file", + )); + } + let mut perms = meta.permissions(); + if perms.mode() & 0o777 != 0o600 { + perms.set_mode(0o600); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +#[cfg(not(unix))] +pub fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + /// 清理供应商名称,确保文件名安全 pub fn sanitize_provider_name(name: &str) -> String { name.chars() @@ -405,6 +531,51 @@ mod tests { set_test_home_override(None); } + + #[test] + fn validate_config_dir_ok_when_not_set() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", None); + assert!(validate_config_dir().is_ok()); + } + + #[test] + fn validate_config_dir_ok_for_normal_path() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new( + "CC_SWITCH_CONFIG_DIR", + Some("/tmp/cc-switch-config-override"), + ); + assert!(validate_config_dir().is_ok()); + } + + #[test] + fn validate_config_dir_rejects_root() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_etc() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/etc")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_usr() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/usr")); + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_tmp() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp")); + assert!(validate_config_dir().is_err()); + } } /// 复制文件 diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 37b3df25..39ab156f 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -481,6 +481,8 @@ impl Database { .join("backups"); fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + crate::config::restrict_dir_permissions(&backup_dir) + .map_err(|e| AppError::io(&backup_dir, e))?; let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let mut backup_id = base_id.clone(); @@ -492,6 +494,22 @@ impl Database { counter += 1; } + // 在打开连接前确保文件权限正确:以 0o600 原子创建 + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; + } + #[cfg(not(unix))] + { + std::fs::File::create(&backup_path).map_err(|e| AppError::io(&backup_path, e))?; + } + { let conn = lock_conn!(self.conn); let mut dest_conn = diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 9865f486..57b2b4ce 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -87,6 +87,30 @@ impl Database { // 确保父目录存在 if let Some(parent) = db_path.parent() { std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + crate::config::restrict_dir_permissions(parent).map_err(|e| AppError::io(parent, e))?; + } + + // 在打开连接前确保文件权限正确:不存在则以 0o600 原子创建,存在则修正权限 + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + if !db_path.exists() { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&db_path) + .map_err(|e| AppError::io(&db_path, e))?; + } else { + crate::config::restrict_file_permissions(&db_path) + .map_err(|e| AppError::io(&db_path, e))?; + } + } + #[cfg(not(unix))] + { + if !db_path.exists() { + std::fs::File::create(&db_path).map_err(|e| AppError::io(&db_path, e))?; + } } let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index a0748efb..9aa5b394 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1478,3 +1478,30 @@ fn schema_model_pricing_is_seeded_on_init() { "新建数据库也应使用修正后的 DeepSeek 定价" ); } + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_sets_restrictive_permissions_on_db_and_dir() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _guard = ConfigDirEnvGuard::set(temp.path()); + + let _db = Database::init().expect("init db"); + + let dir_perms = std::fs::metadata(temp.path()) + .expect("metadata dir") + .permissions() + .mode() + & 0o777; + assert_eq!(dir_perms, 0o700, "config dir should be 0o700"); + + let db_perms = std::fs::metadata(temp.path().join("cc-switch.db")) + .expect("metadata db") + .permissions() + .mode() + & 0o777; + assert_eq!(db_perms, 0o600, "db file should be 0o600"); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 714a18f7..614da4ef 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ pub use claude_plugin::{ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use config::{ get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, read_json_file, + validate_config_dir, }; pub use database::{Database, FailoverQueueItem}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 32b82720..310bf818 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -41,6 +41,7 @@ fn command_uses_own_logger(command: &Option) -> bool { } fn run(cli: Cli) -> Result<(), AppError> { + cc_switch_lib::validate_config_dir()?; initialize_startup_state_if_needed(&cli.command)?; match cli.command { From b13007d379125261d35306864a9ed96a158a8101 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Sat, 30 May 2026 18:35:26 +0800 Subject: [PATCH 02/17] fix: format --- src-tauri/src/config.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ffc3f601..5cee3fe8 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -132,8 +132,8 @@ fn is_system_dir(path: &Path) -> bool { #[cfg(unix)] { const SYSTEM_DIRS: &[&str] = &[ - "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/opt", - "/proc", "/root", "/run", "/sbin", "/sys", "/tmp", "/usr", "/var", + "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", "/opt", "/proc", + "/root", "/run", "/sbin", "/sys", "/tmp", "/usr", "/var", ]; if SYSTEM_DIRS.iter().any(|&sys| path == Path::new(sys)) { return true; @@ -153,10 +153,7 @@ fn is_system_dir(path: &Path) -> bool { "/private/tmp", "/private/var", ]; - if MACOS_SYSTEM_DIRS - .iter() - .any(|&sys| path == Path::new(sys)) - { + if MACOS_SYSTEM_DIRS.iter().any(|&sys| path == Path::new(sys)) { return true; } } From cfe9bc540bf1419b5079e4cb12819e88abd03e48 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:19:13 +0800 Subject: [PATCH 03/17] feat: ask for user permission to restrict folder permission --- src-tauri/src/cli/i18n.rs | 86 ++++++++++ src-tauri/src/config.rs | 267 ++++++++++++++++++++++++++++++- src-tauri/src/database/backup.rs | 10 +- src-tauri/src/database/mod.rs | 49 +++++- src-tauri/src/database/tests.rs | 53 +++++- src-tauri/src/lib.rs | 4 +- 6 files changed, 440 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index b83ec2c4..eacb3b4d 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -10676,6 +10676,92 @@ pub mod texts { "No live providers were imported" } } + + // ----------------------------------------------------------------- + // config.rs - validate_config_dir & prompt_fix_permissions + // ----------------------------------------------------------------- + + pub fn config_dir_is_system_dir(dir: &str, resolved: &str) -> String { + if is_chinese() { + format!("CC_SWITCH_CONFIG_DIR 不能设置为系统目录: {dir}(解析后: {resolved})") + } else { + format!( + "CC_SWITCH_CONFIG_DIR must not be a system directory: {dir} (resolved: {resolved})" + ) + } + } + + pub fn config_permissions_insecure_header() -> &'static str { + if is_chinese() { + "⚠ 检测到以下文件/目录权限不安全:" + } else { + "⚠ Insecure file/directory permissions detected:" + } + } + + pub fn config_permissions_detail(path: &str, current: u32, expected: u32) -> String { + if is_chinese() { + format!(" {path} 当前 {current:04o},期望 {expected:04o}") + } else { + format!(" {path} current {current:04o}, expected {expected:04o}") + } + } + + pub fn config_permissions_fix_prompt() -> &'static str { + if is_chinese() { + "是否现在修复权限?(仅所有者可访问)" + } else { + "Fix permissions now? (owner-only access)" + } + } + + pub fn config_permissions_fixed() -> &'static str { + if is_chinese() { + "✓ 权限已修复" + } else { + "✓ Permissions fixed" + } + } + + pub fn config_permissions_fix_warn_interactive() -> &'static str { + if is_chinese() { + "⚠ 未来版本将拒绝在权限不安全的情况下启动,请尽快修复。" + } else { + "⚠ Future versions will refuse to start with insecure permissions. Please fix soon." + } + } + + pub fn config_permissions_fix_warn_noninteractive() -> &'static str { + if is_chinese() { + "⚠ 检测到配置文件权限不安全(非交互模式),跳过修复。未来版本将拒绝启动。" + } else { + "⚠ Insecure config permissions detected (non-interactive). Skipped. Future versions will refuse to start." + } + } + + pub fn config_permissions_custom_dir_notice(path: &str) -> String { + if is_chinese() { + format!("检测到自定义配置目录: {path}") + } else { + format!("Custom config directory detected: {path}") + } + } + + pub fn config_permissions_confirm_custom_dir() -> &'static str { + if is_chinese() { + "确认要修改此目录的权限吗?" + } else { + "Confirm modifying permissions on this directory?" + } + } + + pub fn config_permissions_custom_dir_skipped() -> &'static str { + if is_chinese() { + "已跳过权限修复。" + } else { + "Skipped permission fix." + } + } } #[cfg(test)] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4a30fb36..80decdf2 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -4,6 +4,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::cli::i18n::texts; use crate::error::AppError; pub(crate) fn home_dir() -> Option { @@ -111,10 +112,9 @@ pub fn validate_config_dir() -> Result<(), AppError> { let resolved = path.canonicalize().unwrap_or_else(|_| path.clone()); if is_system_dir(&path) || is_system_dir(&resolved) { - return Err(AppError::InvalidInput(format!( - "CC_SWITCH_CONFIG_DIR 不能设置为系统目录: {}(解析后: {})", - path.display(), - resolved.display() + return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( + &path.display().to_string(), + &resolved.display().to_string(), ))); } @@ -176,7 +176,7 @@ pub fn get_app_config_path() -> PathBuf { /// 将目录权限收紧为仅所有者可访问(Unix: 0o700) #[cfg(unix)] -pub fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { +pub(crate) fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let meta = fs::metadata(path)?; if !meta.is_dir() { @@ -194,13 +194,13 @@ pub fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { } #[cfg(not(unix))] -pub fn restrict_dir_permissions(_path: &Path) -> std::io::Result<()> { +pub(crate) fn restrict_dir_permissions(_path: &Path) -> std::io::Result<()> { Ok(()) } /// 将文件权限收紧为仅所有者可读写(Unix: 0o600) #[cfg(unix)] -pub fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { +pub(crate) fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let meta = fs::metadata(path)?; if !meta.is_file() { @@ -218,7 +218,129 @@ pub fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { } #[cfg(not(unix))] -pub fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { +pub(crate) fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// 检查配置目录、数据库文件和备份目录的权限是否安全(Unix only) +/// +/// 返回不安全的路径列表:`(路径, 当前权限, 期望权限)` +#[cfg(unix)] +pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { + use std::os::unix::fs::PermissionsExt; + let mut issues = Vec::new(); + let config_dir = get_app_config_dir(); + let db_path = config_dir.join("cc-switch.db"); + let backup_dir = config_dir.join("backups"); + + if config_dir.exists() { + if let Ok(meta) = fs::metadata(&config_dir) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((config_dir.clone(), mode, 0o700)); + } + } + } + + if db_path.exists() { + if let Ok(meta) = fs::metadata(&db_path) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o600 { + issues.push((db_path, mode, 0o600)); + } + } + } + + if backup_dir.exists() { + if let Ok(meta) = fs::metadata(&backup_dir) { + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((backup_dir, mode, 0o700)); + } + } + } + + issues +} + +#[cfg(not(unix))] +pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { + Vec::new() +} + +/// 访问数据库前检查权限,若不安全则提示用户是否修复 +/// +/// - 交互终端:使用 inquire 提示用户,确认后修复,拒绝则警告 +/// - 非交互终端(Docker/管道):仅打印警告到 stderr +pub fn prompt_fix_permissions() -> Result<(), AppError> { + let issues = check_permissions(); + if issues.is_empty() { + return Ok(()); + } + + // In test builds, skip the interactive prompt to avoid blocking on stdin. + if cfg!(test) { + return Ok(()); + } + + let is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdin()) + && std::io::IsTerminal::is_terminal(&std::io::stdout()) + && std::io::IsTerminal::is_terminal(&std::io::stderr()); + + if is_terminal { + eprintln!("{}", texts::config_permissions_insecure_header()); + for (path, current, expected) in &issues { + eprintln!( + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + ); + } + + if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") { + let custom_path = PathBuf::from(&custom); + if !custom_path.as_os_str().is_empty() { + eprintln!( + "{}", + texts::config_permissions_custom_dir_notice(&custom_path.display().to_string()) + ); + let dir_ok = inquire::Confirm::new(texts::config_permissions_confirm_custom_dir()) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?; + if !dir_ok { + eprintln!("{}", texts::config_permissions_custom_dir_skipped()); + return Ok(()); + } + } + } + + let confirm = inquire::Confirm::new(texts::config_permissions_fix_prompt()) + .with_default(true) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?; + + if confirm { + for (path, _, _) in &issues { + if path.is_dir() { + restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; + } else { + restrict_file_permissions(path).map_err(|e| AppError::io(path, e))?; + } + } + eprintln!("{}", texts::config_permissions_fixed()); + } else { + eprintln!("{}", texts::config_permissions_fix_warn_interactive()); + } + } else { + eprintln!("{}", texts::config_permissions_fix_warn_noninteractive()); + for (path, current, expected) in &issues { + eprintln!( + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + ); + } + } + Ok(()) } @@ -573,6 +695,135 @@ mod tests { let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp")); assert!(validate_config_dir().is_err()); } + + #[cfg(unix)] + #[test] + fn check_permissions_returns_empty_for_secure_permissions() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Ensure dir has 0o700 + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set dir perms"); + + // Create a db file with 0o600 + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o600) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert!(issues.is_empty(), "expected no issues, got: {:?}", issues); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_dir() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Set dir to permissive + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, temp.path()); + assert_eq!(issues[0].1, 0o755); + assert_eq!(issues[0].2, 0o700); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_db_file() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Ensure dir has 0o700 so only the db file is flagged + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set dir perms"); + + // Create db file with permissive mode + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, db_path); + assert_eq!(issues[0].1, 0o644); + assert_eq!(issues[0].2, 0o600); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_both_insecure() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + // Set dir to permissive + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + // Create db file with permissive mode + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 2); + } + + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_backup_dir() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let backup_dir = temp.path().join("backups"); + std::fs::create_dir(&backup_dir).expect("create backup dir"); + std::fs::set_permissions(&backup_dir, fs::Permissions::from_mode(0o755)) + .expect("set backup dir perms"); + + let issues = check_permissions(); + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].0, backup_dir); + assert_eq!(issues[0].1, 0o755); + assert_eq!(issues[0].2, 0o700); + } } /// 复制文件 diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 39ab156f..5a08b726 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -2,7 +2,7 @@ //! //! 提供 SQL 导出/导入和二进制快照备份功能。 -use super::{lock_conn, Database, DB_BACKUP_RETAIN}; +use super::{create_secure_dir_all, lock_conn, Database, DB_BACKUP_RETAIN}; use crate::config::get_app_config_dir; use crate::error::AppError; use chrono::Utc; @@ -123,7 +123,7 @@ impl Database { let dump = self.export_sql_string()?; if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + create_secure_dir_all(parent)?; } crate::config::atomic_write(target_path, dump.as_bytes()) @@ -480,9 +480,7 @@ impl Database { .ok_or_else(|| AppError::Config("无效的数据库路径".to_string()))? .join("backups"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - crate::config::restrict_dir_permissions(&backup_dir) - .map_err(|e| AppError::io(&backup_dir, e))?; + create_secure_dir_all(&backup_dir)?; let base_id = format!("db_backup_{}", Utc::now().format("%Y%m%d_%H%M%S")); let mut backup_id = base_id.clone(); @@ -494,7 +492,7 @@ impl Database { counter += 1; } - // 在打开连接前确保文件权限正确:以 0o600 原子创建 + // 新建备份文件时以 0o600 原子创建 #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index da40c631..c381d4f9 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -35,10 +35,11 @@ mod tests; pub(crate) use dao::providers_seed::is_official_seed_id; pub use dao::FailoverQueueItem; -use crate::config::get_app_config_dir; +use crate::config::{get_app_config_dir, restrict_dir_permissions}; use crate::error::AppError; use rusqlite::Connection; use serde::Serialize; +use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; @@ -57,6 +58,41 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { + if path.as_os_str().is_empty() || path.is_dir() { + return Ok(false); + } + + if let Some(parent) = path.parent() { + if parent != path && !parent.as_os_str().is_empty() { + create_secure_dir_all(parent)?; + } + } + + #[cfg(unix)] + let create_result = { + use std::os::unix::fs::DirBuilderExt; + + std::fs::DirBuilder::new().mode(0o700).create(path) + }; + + #[cfg(not(unix))] + let create_result = std::fs::DirBuilder::new().create(path); + + match create_result { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists && path.is_dir() => { + // 竞态:目录在 is_dir() 检查后、create 前被其他进程创建, + // 需要收紧权限以确保安全 + #[cfg(unix)] + restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; + + Ok(false) + } + Err(err) => Err(AppError::io(path, err)), + } +} + /// 安全地获取 Mutex 锁,避免 unwrap panic macro_rules! lock_conn { ($mutex:expr) => { @@ -87,11 +123,13 @@ impl Database { // 确保父目录存在 if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - crate::config::restrict_dir_permissions(parent).map_err(|e| AppError::io(parent, e))?; + create_secure_dir_all(parent)?; } - // 在打开连接前确保文件权限正确:不存在则以 0o600 原子创建,存在则修正权限 + // 在打开数据库前检查已有配置目录、数据库文件和备份目录权限。 + crate::config::prompt_fix_permissions()?; + + // 新建数据库文件时以 0o600 原子创建,已有文件的权限由 prompt_fix_permissions 处理 #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; @@ -102,9 +140,6 @@ impl Database { .mode(0o600) .open(&db_path) .map_err(|e| AppError::io(&db_path, e))?; - } else { - crate::config::restrict_file_permissions(&db_path) - .map_err(|e| AppError::io(&db_path, e))?; } } #[cfg(not(unix))] diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 9aa5b394..11da0e96 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1482,7 +1482,7 @@ fn schema_model_pricing_is_seeded_on_init() { #[test] #[serial_test::serial] #[cfg(unix)] -fn init_sets_restrictive_permissions_on_db_and_dir() { +fn init_creates_db_file_with_restrictive_permissions() { use std::os::unix::fs::PermissionsExt; let _lock = crate::test_support::lock_test_home_and_settings(); @@ -1491,17 +1491,58 @@ fn init_sets_restrictive_permissions_on_db_and_dir() { let _db = Database::init().expect("init db"); - let dir_perms = std::fs::metadata(temp.path()) + let db_perms = std::fs::metadata(temp.path().join("cc-switch.db")) + .expect("metadata db") + .permissions() + .mode() + & 0o777; + assert_eq!(db_perms, 0o600, "new db file should be created with 0o600"); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_creates_config_dir_with_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("new-config-dir"); + let _guard = ConfigDirEnvGuard::set(&config_dir); + + let _db = Database::init().expect("init db"); + + let dir_perms = std::fs::metadata(&config_dir) .expect("metadata dir") .permissions() .mode() & 0o777; - assert_eq!(dir_perms, 0o700, "config dir should be 0o700"); + assert_eq!(dir_perms, 0o700, "new config dir should be 0o700"); +} - let db_perms = std::fs::metadata(temp.path().join("cc-switch.db")) - .expect("metadata db") +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_does_not_silently_fix_existing_dir_permissions() { + use std::os::unix::fs::PermissionsExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _guard = ConfigDirEnvGuard::set(temp.path()); + + // Set dir to a permissive mode before init + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + let _db = Database::init().expect("init db"); + + let dir_perms = std::fs::metadata(temp.path()) + .expect("metadata dir") .permissions() .mode() & 0o777; - assert_eq!(db_perms, 0o600, "db file should be 0o600"); + assert_eq!( + dir_perms, 0o755, + "init should not silently change existing dir permissions" + ); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e1f8be9..73c0e0bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,8 +45,8 @@ pub use claude_plugin::{ }; pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_live_atomic}; pub use config::{ - get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, read_json_file, - validate_config_dir, + check_permissions, get_app_config_dir, get_claude_mcp_path, get_claude_settings_path, + prompt_fix_permissions, read_json_file, validate_config_dir, }; pub use database::{Database, FailoverQueueItem}; pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest}; From a28227ee1085033c0118f69e3decd5663af973e1 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:32:13 +0800 Subject: [PATCH 04/17] fix: remove untested codes on windows --- src-tauri/src/config.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 80decdf2..09b62d4d 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -161,9 +161,8 @@ fn is_system_dir(path: &Path) -> bool { // Windows: 盘符根目录(如 C:\) #[cfg(windows)] { - if path.parent().is_none() && path.drive().is_some() { - return true; - } + // Should do some more verifications here + false } false From 5988ff0e19c1a5faaa3a09d4d671dee53fbb84ac Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 01:46:52 +0800 Subject: [PATCH 05/17] docs: add comments to explain create_secure_dir_all function design --- src-tauri/src/database/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index c381d4f9..b056f6e7 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -58,6 +58,10 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { if path.as_os_str().is_empty() || path.is_dir() { return Ok(false); From 1854cd4af640a6b255f2f3ad59d44ec55842c230 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:00:25 +0800 Subject: [PATCH 06/17] fix: permissions of backup file --- src-tauri/src/services/config.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index d16b10d1..b83dc0d2 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -59,11 +59,13 @@ impl ConfigService { .ok_or_else(|| AppError::Config("Invalid config path".into()))? .join("backups"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + crate::database::create_secure_dir_all(&backup_dir)?; let backup_path = backup_dir.join(format!("{backup_id}.sql")); let db = Database::init()?; db.export_sql(&backup_path)?; + crate::config::restrict_file_permissions(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; From 5a33fb6edb9b2809907d4e19163672adf383192e Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 02:01:06 +0800 Subject: [PATCH 07/17] refactor: move config dir validation to db init function --- src-tauri/src/config.rs | 8 +------- src-tauri/src/database/mod.rs | 4 +++- src-tauri/src/database/tests.rs | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 09b62d4d..86f8420e 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -100,13 +100,7 @@ pub fn get_app_config_dir() -> PathBuf { /// 拒绝系统关键目录(如 `/`、`/etc`、`/usr` 等),防止下游权限操作破坏系统。 /// 未设置环境变量时默认路径 `~/.cc-switch` 始终安全,直接放行。 pub fn validate_config_dir() -> Result<(), AppError> { - let Some(raw) = env::var_os("CC_SWITCH_CONFIG_DIR") else { - return Ok(()); - }; - let path = PathBuf::from(&raw); - if path.as_os_str().is_empty() || path.to_string_lossy().trim().is_empty() { - return Ok(()); - } + let path = get_app_config_dir(); // 检查原始路径和 canonicalize 后的路径(macOS 下 /etc -> /private/etc) let resolved = path.canonicalize().unwrap_or_else(|_| path.clone()); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index b056f6e7..fcb95e7c 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -60,7 +60,7 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { if path.as_os_str().is_empty() || path.is_dir() { @@ -123,6 +123,8 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { + crate::config::validate_config_dir()?; + let db_path = get_app_config_dir().join("cc-switch.db"); // 确保父目录存在 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 11da0e96..7fddb18c 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1479,6 +1479,21 @@ fn schema_model_pricing_is_seeded_on_init() { ); } +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_system_config_dir() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _guard = ConfigDirEnvGuard::set(Path::new("/etc")); + + let result = Database::init(); + + assert!( + result.is_err(), + "Database::init should reject system CC_SWITCH_CONFIG_DIR values" + ); +} + #[test] #[serial_test::serial] #[cfg(unix)] From f6657412f13668a0c3e8e8fe2196b36d17eadb01 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:32:30 +0800 Subject: [PATCH 08/17] fix: solve repeat permission query issue --- src-tauri/src/database/mod.rs | 5 ----- src-tauri/src/database/tests.rs | 15 --------------- src-tauri/src/main.rs | 17 ++++++++++++++++- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index fcb95e7c..d60bf46f 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -123,8 +123,6 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { - crate::config::validate_config_dir()?; - let db_path = get_app_config_dir().join("cc-switch.db"); // 确保父目录存在 @@ -132,9 +130,6 @@ impl Database { create_secure_dir_all(parent)?; } - // 在打开数据库前检查已有配置目录、数据库文件和备份目录权限。 - crate::config::prompt_fix_permissions()?; - // 新建数据库文件时以 0o600 原子创建,已有文件的权限由 prompt_fix_permissions 处理 #[cfg(unix)] { diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 7fddb18c..11da0e96 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1479,21 +1479,6 @@ fn schema_model_pricing_is_seeded_on_init() { ); } -#[test] -#[serial_test::serial] -#[cfg(unix)] -fn init_rejects_system_config_dir() { - let _lock = crate::test_support::lock_test_home_and_settings(); - let _guard = ConfigDirEnvGuard::set(Path::new("/etc")); - - let result = Database::init(); - - assert!( - result.is_err(), - "Database::init should reject system CC_SWITCH_CONFIG_DIR values" - ); -} - #[test] #[serial_test::serial] #[cfg(unix)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 310bf818..15cf7064 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -41,7 +41,13 @@ fn command_uses_own_logger(command: &Option) -> bool { } fn run(cli: Cli) -> Result<(), AppError> { - cc_switch_lib::validate_config_dir()?; + if database_access_required(&cli.command) { + // 在打开数据库前检查已有配置目录、数据库文件和备份目录权限。 + // This ensures the chmod happens before database initialization, + // and also ensures that the user is only queried once. + cc_switch_lib::validate_config_dir()?; + cc_switch_lib::prompt_fix_permissions()?; + } initialize_startup_state_if_needed(&cli.command)?; match cli.command { @@ -95,6 +101,15 @@ fn initialize_startup_state_if_needed(command: &Option) -> Result<(), Ok(()) } +fn database_access_required(command: &Option) -> bool { + match command { + Some(Commands::Completions(_)) + | Some(Commands::Update(_)) + | Some(Commands::Internal(_)) => false, + _ => true, + } +} + #[cfg(test)] mod tests { use super::{ From 9fddd40f3458f47a1b6c957255e4ebeb619167fa Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:57:00 +0800 Subject: [PATCH 09/17] test: add tests for prompt_fix_permissions function --- src-tauri/src/cli/i18n.rs | 4 +- src-tauri/src/config.rs | 308 +++++++++++++++++++++++++++++++------- src-tauri/src/main.rs | 35 ++++- 3 files changed, 288 insertions(+), 59 deletions(-) diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index eacb3b4d..a663259e 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -10741,9 +10741,9 @@ pub mod texts { pub fn config_permissions_custom_dir_notice(path: &str) -> String { if is_chinese() { - format!("检测到自定义配置目录: {path}") + format!("检测到自定义配置目录: {path},请核实此目录不是关键系统目录") } else { - format!("Custom config directory detected: {path}") + format!("Custom config directory detected: {path}, please verify this is not a critical system directory") } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 86f8420e..9e079f03 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -261,6 +261,90 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { Vec::new() } +trait PermissionPrompter { + fn confirm_custom_dir(&mut self, path: &Path) -> Result; + fn confirm_fix(&mut self) -> Result; +} + +struct InquirePermissionPrompter; + +impl PermissionPrompter for InquirePermissionPrompter { + fn confirm_custom_dir(&mut self, _path: &Path) -> Result { + inquire::Confirm::new(texts::config_permissions_confirm_custom_dir()) + .with_default(false) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e))) + } + + fn confirm_fix(&mut self) -> Result { + inquire::Confirm::new(texts::config_permissions_fix_prompt()) + .with_default(true) + .prompt() + .map_err(|e| AppError::Message(format!("Prompt failed: {}", e))) + } +} + +fn prompt_fix_permissions_interactive( + issues: &[(PathBuf, u32, u32)], + custom_dir: Option, + prompter: &mut dyn PermissionPrompter, +) -> Result<(), AppError> { + eprintln!("{}", texts::config_permissions_insecure_header()); + for (path, current, expected) in issues { + eprintln!( + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + ); + } + + if let Some(custom_path) = custom_dir { + if !custom_path.as_os_str().is_empty() { + eprintln!( + "{}", + texts::config_permissions_custom_dir_notice(&custom_path.display().to_string()) + ); + if !prompter.confirm_custom_dir(&custom_path)? { + eprintln!("{}", texts::config_permissions_custom_dir_skipped()); + return Ok(()); + } + } + } + + if prompter.confirm_fix()? { + for (path, _, _) in issues { + if path.is_dir() { + restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; + } else { + restrict_file_permissions(path).map_err(|e| AppError::io(path, e))?; + } + } + eprintln!("{}", texts::config_permissions_fixed()); + } else { + eprintln!("{}", texts::config_permissions_fix_warn_interactive()); + } + + Ok(()) +} + +fn write_permissions_noninteractive_warning( + mut output: W, + issues: &[(PathBuf, u32, u32)], +) -> std::io::Result<()> { + writeln!( + output, + "{}", + texts::config_permissions_fix_warn_noninteractive() + )?; + for (path, current, expected) in issues { + writeln!( + output, + "{}", + texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) + )?; + } + Ok(()) +} + /// 访问数据库前检查权限,若不安全则提示用户是否修复 /// /// - 交互终端:使用 inquire 提示用户,确认后修复,拒绝则警告 @@ -271,67 +355,20 @@ pub fn prompt_fix_permissions() -> Result<(), AppError> { return Ok(()); } - // In test builds, skip the interactive prompt to avoid blocking on stdin. - if cfg!(test) { - return Ok(()); - } - - let is_terminal = std::io::IsTerminal::is_terminal(&std::io::stdin()) + let is_terminal = !cfg!(test) + && std::io::IsTerminal::is_terminal(&std::io::stdin()) && std::io::IsTerminal::is_terminal(&std::io::stdout()) && std::io::IsTerminal::is_terminal(&std::io::stderr()); if is_terminal { - eprintln!("{}", texts::config_permissions_insecure_header()); - for (path, current, expected) in &issues { - eprintln!( - "{}", - texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) - ); - } - - if let Some(custom) = env::var_os("CC_SWITCH_CONFIG_DIR") { - let custom_path = PathBuf::from(&custom); - if !custom_path.as_os_str().is_empty() { - eprintln!( - "{}", - texts::config_permissions_custom_dir_notice(&custom_path.display().to_string()) - ); - let dir_ok = inquire::Confirm::new(texts::config_permissions_confirm_custom_dir()) - .with_default(false) - .prompt() - .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?; - if !dir_ok { - eprintln!("{}", texts::config_permissions_custom_dir_skipped()); - return Ok(()); - } - } - } - - let confirm = inquire::Confirm::new(texts::config_permissions_fix_prompt()) - .with_default(true) - .prompt() - .map_err(|e| AppError::Message(format!("Prompt failed: {}", e)))?; - - if confirm { - for (path, _, _) in &issues { - if path.is_dir() { - restrict_dir_permissions(path).map_err(|e| AppError::io(path, e))?; - } else { - restrict_file_permissions(path).map_err(|e| AppError::io(path, e))?; - } - } - eprintln!("{}", texts::config_permissions_fixed()); - } else { - eprintln!("{}", texts::config_permissions_fix_warn_interactive()); - } + let custom_dir = env::var_os("CC_SWITCH_CONFIG_DIR").map(PathBuf::from); + let mut prompter = InquirePermissionPrompter; + prompt_fix_permissions_interactive(&issues, custom_dir, &mut prompter)?; } else { - eprintln!("{}", texts::config_permissions_fix_warn_noninteractive()); - for (path, current, expected) in &issues { - eprintln!( - "{}", - texts::config_permissions_detail(&path.display().to_string(), *current, *expected,) - ); - } + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + write_permissions_noninteractive_warning(&mut stderr, &issues) + .map_err(|e| AppError::Message(format!("Failed to write permission warning: {e}")))?; } Ok(()) @@ -501,6 +538,36 @@ mod tests { } } + struct FakePermissionPrompter { + custom_dir_response: bool, + fix_response: bool, + custom_dir_calls: usize, + fix_calls: usize, + } + + impl FakePermissionPrompter { + fn new(custom_dir_response: bool, fix_response: bool) -> Self { + Self { + custom_dir_response, + fix_response, + custom_dir_calls: 0, + fix_calls: 0, + } + } + } + + impl PermissionPrompter for FakePermissionPrompter { + fn confirm_custom_dir(&mut self, _path: &Path) -> Result { + self.custom_dir_calls += 1; + Ok(self.custom_dir_response) + } + + fn confirm_fix(&mut self) -> Result { + self.fix_calls += 1; + Ok(self.fix_response) + } + } + #[test] fn derive_mcp_path_from_override_preserves_folder_name() { let override_dir = PathBuf::from("/tmp/profile/.claude"); @@ -817,6 +884,135 @@ mod tests { assert_eq!(issues[0].1, 0o755); assert_eq!(issues[0].2, 0o700); } + + #[cfg(unix)] + #[test] + fn prompt_fix_permissions_does_not_fix_in_test_build() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + + prompt_fix_permissions().expect("test build should only warn"); + + // Permissions should remain unchanged because cfg!(test) skips the fix logic + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o755, + "test build should not modify directory permissions" + ); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_permissions_when_confirmed() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive fix should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o700); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_file_permissions_when_confirmed() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + let db_path = temp.path().join("cc-switch.db"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&db_path) + .expect("create db file"); + let issues = vec![(db_path.clone(), 0o644, 0o600)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive file fix should succeed"); + + let mode = std::fs::metadata(&db_path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_leaves_permissions_when_fix_declined() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(true, false); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive prompt should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_skips_custom_dir_when_not_confirmed() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) + .expect("set dir perms"); + let custom_dir = temp.path().to_path_buf(); + let issues = vec![(custom_dir.clone(), 0o755, 0o700)]; + let mut prompter = FakePermissionPrompter::new(false, true); + + prompt_fix_permissions_interactive(&issues, Some(custom_dir), &mut prompter) + .expect("interactive prompt should succeed"); + + let mode = std::fs::metadata(temp.path()) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + assert_eq!(prompter.custom_dir_calls, 1); + assert_eq!(prompter.fix_calls, 0); + } } /// 复制文件 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 15cf7064..d3e44167 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -113,7 +113,8 @@ fn database_access_required(command: &Option) -> bool { #[cfg(test)] mod tests { use super::{ - command_requires_startup_state, command_uses_own_logger, initialize_startup_state_if_needed, + command_requires_startup_state, command_uses_own_logger, database_access_required, + initialize_startup_state_if_needed, }; use cc_switch_lib::cli::Cli; use clap::Parser; @@ -198,6 +199,38 @@ mod tests { assert!(command_requires_startup_state(&provider.command)); } + #[test] + fn completions_update_internal_skip_database_access() { + let update = Cli::parse_from(["cc-switch", "update"]); + let completions = Cli::parse_from(["cc-switch", "completions", "bash"]); + let internal = Cli::parse_from([ + "cc-switch", + "internal", + "capture-codex-temp", + "official", + "/tmp/codex-home", + ]); + + assert!(!database_access_required(&update.command)); + assert!(!database_access_required(&completions.command)); + assert!(!database_access_required(&internal.command)); + } + + #[test] + fn normal_commands_require_database_access() { + let provider = Cli::parse_from(["cc-switch", "provider", "list"]); + let mcp = Cli::parse_from(["cc-switch", "mcp", "list"]); + let config = Cli::parse_from(["cc-switch", "config", "validate"]); + let proxy = Cli::parse_from(["cc-switch", "proxy", "show"]); + let interactive = Cli::parse_from(["cc-switch"]); + + assert!(database_access_required(&provider.command)); + assert!(database_access_required(&mcp.command)); + assert!(database_access_required(&config.command)); + assert!(database_access_required(&proxy.command)); + assert!(database_access_required(&interactive.command)); + } + #[test] #[serial] fn update_bypasses_future_schema_database_gate() { From 8b47ef482340453fb06e9d33cfb54ad4d7f2be7f Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:03:43 +0800 Subject: [PATCH 10/17] fix: incorrect tmp folder --- src-tauri/src/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 9e079f03..b8235d51 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -917,7 +917,7 @@ mod tests { fn interactive_permission_prompt_fixes_permissions_when_confirmed() { use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + let temp = tempfile::tempdir().expect("create temp dir"); std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) .expect("set dir perms"); let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; @@ -941,7 +941,7 @@ mod tests { fn interactive_permission_prompt_fixes_file_permissions_when_confirmed() { use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + let temp = tempfile::tempdir().expect("create temp dir"); let db_path = temp.path().join("cc-switch.db"); std::fs::OpenOptions::new() .write(true) @@ -970,7 +970,7 @@ mod tests { fn interactive_permission_prompt_leaves_permissions_when_fix_declined() { use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + let temp = tempfile::tempdir().expect("create temp dir"); std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) .expect("set dir perms"); let issues = vec![(temp.path().to_path_buf(), 0o755, 0o700)]; @@ -994,7 +994,7 @@ mod tests { fn interactive_permission_prompt_skips_custom_dir_when_not_confirmed() { use std::os::unix::fs::PermissionsExt; - let temp = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).expect("create temp dir"); + let temp = tempfile::tempdir().expect("create temp dir"); std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o755)) .expect("set dir perms"); let custom_dir = temp.path().to_path_buf(); From c656012b2b2a2f8da7aeda5eca7f8081c212a731 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:38:53 +0800 Subject: [PATCH 11/17] fix: correct canonicalize and reject config folders --- src-tauri/src/cli/i18n.rs | 43 +++++++++++++++++ src-tauri/src/config.rs | 86 +++++++++++++++++++++++++++++++-- src-tauri/src/database/mod.rs | 10 +++- src-tauri/src/database/tests.rs | 33 +++++++++++++ 4 files changed, 168 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index a663259e..a2cb7e86 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -10691,6 +10691,22 @@ pub mod texts { } } + pub fn config_dir_invalid_last_component(path: &str) -> String { + if is_chinese() { + format!("配置目录路径无效,无法解析最后一层目录: {path}") + } else { + format!("Invalid config directory path; unable to resolve the final directory component: {path}") + } + } + + pub fn config_dir_only_final_component_may_be_missing(path: &str) -> String { + if is_chinese() { + format!("配置目录路径无效,仅允许最后一层目录不存在: {path}") + } else { + format!("Invalid config directory path; only the final directory component may be missing: {path}") + } + } + pub fn config_permissions_insecure_header() -> &'static str { if is_chinese() { "⚠ 检测到以下文件/目录权限不安全:" @@ -10809,6 +10825,33 @@ mod tests { assert!(!help.contains("Settings:")); } + #[test] + fn config_dir_validation_messages_are_localized() { + { + let _lang = use_test_language(Language::English); + assert_eq!( + texts::config_dir_invalid_last_component("/tmp/child/.."), + "Invalid config directory path; unable to resolve the final directory component: /tmp/child/.." + ); + assert_eq!( + texts::config_dir_only_final_component_may_be_missing("/tmp/child/.."), + "Invalid config directory path; only the final directory component may be missing: /tmp/child/.." + ); + } + + { + let _lang = use_test_language(Language::Chinese); + assert_eq!( + texts::config_dir_invalid_last_component("/tmp/child/.."), + "配置目录路径无效,无法解析最后一层目录: /tmp/child/.." + ); + assert_eq!( + texts::config_dir_only_final_component_may_be_missing("/tmp/child/.."), + "配置目录路径无效,仅允许最后一层目录不存在: /tmp/child/.." + ); + } + } + #[test] fn proxy_dashboard_copy_is_fully_localized_in_chinese() { let _lang = use_test_language(Language::Chinese); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b8235d51..ffcc8ac4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -101,9 +101,7 @@ pub fn get_app_config_dir() -> PathBuf { /// 未设置环境变量时默认路径 `~/.cc-switch` 始终安全,直接放行。 pub fn validate_config_dir() -> Result<(), AppError> { let path = get_app_config_dir(); - - // 检查原始路径和 canonicalize 后的路径(macOS 下 /etc -> /private/etc) - let resolved = path.canonicalize().unwrap_or_else(|_| path.clone()); + let resolved = resolve_existing_or_new_child_path(&path)?; if is_system_dir(&path) || is_system_dir(&resolved) { return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( @@ -115,6 +113,47 @@ pub fn validate_config_dir() -> Result<(), AppError> { Ok(()) } +pub(crate) fn resolve_existing_or_new_child_path(path: &Path) -> Result { + match path.canonicalize() { + Ok(resolved) => Ok(resolved), + Err(original_err) => { + let file_name = path.file_name().ok_or_else(|| { + AppError::InvalidInput(texts::config_dir_invalid_last_component( + &path.display().to_string(), + )) + })?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent_resolved = + parent + .canonicalize() + .map_err(|parent_err| AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: parent_err, + })?; + + let resolved = parent_resolved.join(file_name); + if is_system_dir(&resolved) { + return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( + &path.display().to_string(), + &resolved.display().to_string(), + ))); + } + + log::debug!( + "Config dir does not exist yet, resolved parent and rebuilt path: {} -> {} ({original_err})", + path.display(), + resolved.display() + ); + Ok(resolved) + } + } +} + /// 判断路径是否为系统关键目录(不应被应用修改权限) fn is_system_dir(path: &Path) -> bool { // 根目录 @@ -756,6 +795,47 @@ mod tests { assert!(validate_config_dir().is_err()); } + #[test] + fn validate_config_dir_allows_parent_dir_components_when_parent_resolves() { + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::create_dir(temp.path().join("child")).expect("create child dir"); + let config_dir = temp.path().join("child").join("..").join("cc-switch"); + let _env = ConfigDirEnvGuard::new( + "CC_SWITCH_CONFIG_DIR", + Some(config_dir.to_str().expect("utf8 temp path")), + ); + + assert!(validate_config_dir().is_ok()); + assert_eq!( + resolve_existing_or_new_child_path(&config_dir).expect("resolve config dir"), + temp.path() + .canonicalize() + .expect("canonicalize temp dir") + .join("cc-switch") + ); + } + + #[test] + fn validate_config_dir_rejects_parent_dir_components_when_parent_does_not_resolve() { + let _guard = lock_test_home_and_settings(); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/tmp/cc-switch-new-child/..")); + + assert!(validate_config_dir().is_err()); + } + + #[test] + fn validate_config_dir_rejects_parent_dir_components_when_resolved_to_system_dir() { + let _guard = lock_test_home_and_settings(); + let _env = ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some("/usr/bin/..")); + + assert!( + validate_config_dir().is_err(), + "resolved config dir should reject the system parent directory" + ); + } + #[cfg(unix)] #[test] fn check_permissions_returns_empty_for_secure_permissions() { diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index d60bf46f..0cb2c2d8 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -35,7 +35,9 @@ mod tests; pub(crate) use dao::providers_seed::is_official_seed_id; pub use dao::FailoverQueueItem; -use crate::config::{get_app_config_dir, restrict_dir_permissions}; +use crate::config::{ + get_app_config_dir, resolve_existing_or_new_child_path, restrict_dir_permissions, +}; use crate::error::AppError; use rusqlite::Connection; use serde::Serialize; @@ -62,7 +64,13 @@ pub(crate) fn to_json_string(value: &T) -> Result Result { + let resolved_path = resolve_existing_or_new_child_path(path)?; + create_secure_dir_all_resolved(&resolved_path) +} + +fn create_secure_dir_all_resolved(path: &Path) -> Result { if path.as_os_str().is_empty() || path.is_dir() { return Ok(false); } diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 11da0e96..0e7ce4d3 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1520,6 +1520,39 @@ fn init_creates_config_dir_with_restrictive_permissions() { assert_eq!(dir_perms, 0o700, "new config dir should be 0o700"); } +#[test] +#[cfg(unix)] +fn create_secure_dir_all_rejects_unresolved_parent_dir_components_without_chmodding_parent() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o755)) + .expect("set parent dir perms"); + + let path = temp.path().join("child").join(".."); + let err = create_secure_dir_all(&path).expect_err("unresolved parent should be rejected"); + let message = err.to_string(); + + assert!( + message.contains("配置目录路径无效") || message.contains("Invalid config directory path"), + "unexpected error: {message}" + ); + assert!( + !temp.path().join("child").exists(), + "rejected path should not create intermediate directories" + ); + + let parent_perms = std::fs::metadata(temp.path()) + .expect("metadata parent") + .permissions() + .mode() + & 0o777; + assert_eq!( + parent_perms, 0o755, + "rejected path should not chmod the parent directory" + ); +} + #[test] #[serial_test::serial] #[cfg(unix)] From 1eb7ae45c82fc649da8787a4db783af7e8ca5b3b Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:44:48 +0800 Subject: [PATCH 12/17] fix: check and log insecure permissions, reject insecure config folder when initializing db --- src-tauri/src/database/mod.rs | 29 ++++++++++++++++++++++++++++- src-tauri/src/database/tests.rs | 16 ++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 0cb2c2d8..1d65cdd3 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -43,13 +43,15 @@ use rusqlite::Connection; use serde::Serialize; use std::path::Path; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::Mutex; +use std::sync::{Mutex, Once}; // DAO 方法通过 impl Database 提供,无需额外导出 /// 数据库备份保留数量 const DB_BACKUP_RETAIN: usize = 10; +static DATABASE_PERMISSION_CHECK: Once = Once::new(); + /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 pub(crate) const SCHEMA_VERSION: i32 = 10; @@ -131,6 +133,12 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { + if let Err(err) = crate::config::validate_config_dir() { + log::warn!("拒绝初始化数据库:配置目录校验失败: {err}"); + return Err(err); + } + warn_insecure_permissions_once(); + let db_path = get_app_config_dir().join("cc-switch.db"); // 确保父目录存在 @@ -246,3 +254,22 @@ impl Database { &self.runtime_key } } + +fn warn_insecure_permissions_once() { + DATABASE_PERMISSION_CHECK.call_once(|| { + let issues = crate::config::check_permissions(); + if issues.is_empty() { + return; + } + + log::warn!("检测到不安全的 cc-switch 配置权限,请收紧后再继续使用"); + for (path, current, expected) in issues { + log::warn!( + "不安全权限: path={} current={:03o} expected={:03o}", + path.display(), + current, + expected + ); + } + }); +} diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 0e7ce4d3..e78d2d7e 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -234,6 +234,22 @@ fn init_rejects_future_schema_before_creating_tables() { ); } +#[test] +#[serial_test::serial] +fn init_rejects_unsafe_config_dir() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let _guard = ConfigDirEnvGuard::set(Path::new("/tmp")); + + let err = match Database::init() { + Ok(_) => panic!("unsafe config dir should fail init"), + Err(err) => err, + }; + assert!( + err.to_string().contains("CC_SWITCH_CONFIG_DIR"), + "unexpected error: {err}" + ); +} + #[test] fn schema_migration_adds_missing_columns_for_providers() { let conn = Connection::open_in_memory().expect("open memory db"); From 09746605346b3a048046fa7929aa3ed010c23a1d Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:46:49 +0800 Subject: [PATCH 13/17] fix: internal command needs database access --- src-tauri/src/main.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d3e44167..59d25c9d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -103,9 +103,7 @@ fn initialize_startup_state_if_needed(command: &Option) -> Result<(), fn database_access_required(command: &Option) -> bool { match command { - Some(Commands::Completions(_)) - | Some(Commands::Update(_)) - | Some(Commands::Internal(_)) => false, + Some(Commands::Completions(_)) | Some(Commands::Update(_)) => false, _ => true, } } @@ -200,20 +198,12 @@ mod tests { } #[test] - fn completions_update_internal_skip_database_access() { + fn completions_update_skip_database_access() { let update = Cli::parse_from(["cc-switch", "update"]); let completions = Cli::parse_from(["cc-switch", "completions", "bash"]); - let internal = Cli::parse_from([ - "cc-switch", - "internal", - "capture-codex-temp", - "official", - "/tmp/codex-home", - ]); assert!(!database_access_required(&update.command)); assert!(!database_access_required(&completions.command)); - assert!(!database_access_required(&internal.command)); } #[test] @@ -223,12 +213,20 @@ mod tests { let config = Cli::parse_from(["cc-switch", "config", "validate"]); let proxy = Cli::parse_from(["cc-switch", "proxy", "show"]); let interactive = Cli::parse_from(["cc-switch"]); + let internal = Cli::parse_from([ + "cc-switch", + "internal", + "capture-codex-temp", + "official", + "/tmp/codex-home", + ]); assert!(database_access_required(&provider.command)); assert!(database_access_required(&mcp.command)); assert!(database_access_required(&config.command)); assert!(database_access_required(&proxy.command)); assert!(database_access_required(&interactive.command)); + assert!(database_access_required(&internal.command)); } #[test] From 489ad4a22da4fb2e71988d4045aff01b1282bde5 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:57:29 +0800 Subject: [PATCH 14/17] fix: add warnings for existing backup files --- src-tauri/src/config.rs | 161 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ffcc8ac4..36fb5f85 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -254,7 +254,7 @@ pub(crate) fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { Ok(()) } -/// 检查配置目录、数据库文件和备份目录的权限是否安全(Unix only) +/// 检查配置目录、敏感配置/数据文件和备份目录的权限是否安全(Unix only) /// /// 返回不安全的路径列表:`(路径, 当前权限, 期望权限)` #[cfg(unix)] @@ -262,7 +262,6 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { use std::os::unix::fs::PermissionsExt; let mut issues = Vec::new(); let config_dir = get_app_config_dir(); - let db_path = config_dir.join("cc-switch.db"); let backup_dir = config_dir.join("backups"); if config_dir.exists() { @@ -274,15 +273,6 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { } } - if db_path.exists() { - if let Ok(meta) = fs::metadata(&db_path) { - let mode = meta.permissions().mode() & 0o777; - if mode != 0o600 { - issues.push((db_path, mode, 0o600)); - } - } - } - if backup_dir.exists() { if let Ok(meta) = fs::metadata(&backup_dir) { let mode = meta.permissions().mode() & 0o777; @@ -292,6 +282,8 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { } } + collect_sensitive_file_permission_issues(&config_dir, &mut issues); + issues } @@ -300,6 +292,50 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { Vec::new() } +#[cfg(unix)] +fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + use std::os::unix::fs::PermissionsExt; + + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + + let mut entries = entries.filter_map(Result::ok).collect::>(); + entries.sort_by_key(|entry| entry.path()); + + for entry in entries { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + + if file_type.is_dir() { + collect_sensitive_file_permission_issues(&path, issues); + } else if file_type.is_file() && is_sensitive_config_file(&path) { + let Ok(meta) = fs::metadata(&path) else { + continue; + }; + let mode = meta.permissions().mode() & 0o777; + if is_insecure_sensitive_file_mode(mode) { + issues.push((path, mode, 0o600)); + } + } + } +} + +#[cfg(unix)] +fn is_sensitive_config_file(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "db" | "sql")) + .unwrap_or(false) +} + +#[cfg(unix)] +fn is_insecure_sensitive_file_mode(mode: u32) -> bool { + mode & !0o600 != 0 +} + trait PermissionPrompter { fn confirm_custom_dir(&mut self, path: &Path) -> Result; fn confirm_fix(&mut self) -> Result; @@ -965,6 +1001,109 @@ mod tests { assert_eq!(issues[0].2, 0o700); } + #[cfg(unix)] + #[test] + fn check_permissions_detects_insecure_sensitive_files_recursively() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let backup_dir = temp.path().join("backups"); + let nested = backup_dir.join("nested"); + std::fs::create_dir_all(&nested).expect("create nested dir"); + std::fs::set_permissions(&backup_dir, fs::Permissions::from_mode(0o700)) + .expect("set backup dir perms"); + + let root_json = temp.path().join("config.json"); + let nested_sql = nested.join("backup.sql"); + let nested_db = nested.join("snapshot.db"); + for path in [&root_json, &nested_sql, &nested_db] { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(path) + .expect("create sensitive file"); + } + + let issues = check_permissions(); + let issue_paths = issues + .iter() + .map(|(path, current, expected)| (path.clone(), *current, *expected)) + .collect::>(); + + assert_eq!(issues.len(), 3); + assert!(issue_paths.contains(&(root_json, 0o644, 0o600))); + assert!(issue_paths.contains(&(nested_sql, 0o644, 0o600))); + assert!(issue_paths.contains(&(nested_db, 0o644, 0o600))); + } + + #[cfg(unix)] + #[test] + fn check_permissions_allows_more_restrictive_sensitive_file_permissions() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(temp.path().to_str().unwrap())); + + std::fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + + let read_only = temp.path().join("read-only.json"); + let write_only = temp.path().join("write-only.sql"); + for (path, mode) in [(&read_only, 0o400), (&write_only, 0o200)] { + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(mode) + .open(path) + .expect("create sensitive file"); + std::fs::set_permissions(path, fs::Permissions::from_mode(mode)) + .expect("set sensitive file perms"); + } + + let issues = check_permissions(); + assert!(issues.is_empty(), "expected no issues, got: {:?}", issues); + } + + #[cfg(unix)] + #[test] + fn interactive_permission_prompt_fixes_recursive_sensitive_files_when_confirmed() { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp.path().join("nested"); + std::fs::create_dir(&nested).expect("create nested dir"); + let json_path = nested.join("settings.json"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&json_path) + .expect("create json file"); + let issues = vec![(json_path.clone(), 0o644, 0o600)]; + let mut prompter = FakePermissionPrompter::new(true, true); + + prompt_fix_permissions_interactive(&issues, None, &mut prompter) + .expect("interactive recursive file fix should succeed"); + + let mode = std::fs::metadata(&json_path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(prompter.custom_dir_calls, 0); + assert_eq!(prompter.fix_calls, 1); + } + #[cfg(unix)] #[test] fn prompt_fix_permissions_does_not_fix_in_test_build() { From 02c4accb2950cbafd63c77064bdd85c14d74060e Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 01:11:50 +0800 Subject: [PATCH 15/17] fix: test fail of check_permissions_detects_insecure_sensitive_files_recursively --- src-tauri/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 36fb5f85..21412249 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -327,7 +327,7 @@ fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBu fn is_sensitive_config_file(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) - .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "db" | "sql")) + .map(|ext| matches!(ext.to_ascii_lowercase().as_str(), "db" | "json" | "sql")) .unwrap_or(false) } From 8037b4dacb97fd8f42f29d1f18a1fef3f73b9c75 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:30:53 +0800 Subject: [PATCH 16/17] fix: early drop of the temp folders --- src-tauri/src/cli/tui/app/tests.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 0d38221b..1c3b5c6b 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -10409,7 +10409,8 @@ mod tests { #[test] #[serial] fn prompt_save_runtime_creates_prompt_from_one_page_form() { - let _guard = TestEnvGuard::isolated(tempfile::tempdir().expect("tempdir").path()); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = TestEnvGuard::isolated(temp.path()); let state = crate::AppState::try_new().expect("load state"); state.save().expect("persist empty state"); @@ -10498,7 +10499,8 @@ mod tests { #[test] #[serial] fn prompt_create_runtime_clears_filter_when_new_prompt_is_not_visible() { - let _guard = TestEnvGuard::isolated(tempfile::tempdir().expect("tempdir").path()); + let temp = tempfile::tempdir().expect("tempdir"); + let _guard = TestEnvGuard::isolated(temp.path()); let state = crate::AppState::try_new().expect("load state"); state.save().expect("persist empty state"); From 3b73fc3033d0c51c1d15803f7650ee5f6620c794 Mon Sep 17 00:00:00 2001 From: FeiYehua <31472675+feiyehua@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:53:40 +0800 Subject: [PATCH 17/17] fix: update benchmark to use correct file permissions --- scripts/benchmark_cc_switch.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/benchmark_cc_switch.py b/scripts/benchmark_cc_switch.py index cd141574..e585061e 100755 --- a/scripts/benchmark_cc_switch.py +++ b/scripts/benchmark_cc_switch.py @@ -138,6 +138,9 @@ def read_json(path: Path) -> dict: def write_json(path: Path, data: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + # cc-switch checks that sensitive files (.json, .db) have 0600 on Unix. + if sys.platform != "win32": + path.chmod(0o600) @dataclass @@ -188,7 +191,11 @@ def configure_environment(real_env: bool) -> BenchEnvironment: } old_env = {key: os.environ.get(key) for key in env_updates} for path in env_updates.values(): - Path(path).mkdir(parents=True, exist_ok=True) + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + # cc-switch checks that its config dir has 0700 permissions on Unix. + if path == env_updates["CC_SWITCH_CONFIG_DIR"]: + p.chmod(0o700) for key, value in env_updates.items(): os.environ[key] = value return BenchEnvironment(mode="sandbox", root=root, old_env=old_env) @@ -325,6 +332,9 @@ def connect_db(paths: Paths) -> sqlite3.Connection: paths.cc_dir.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(paths.db_path) conn.execute("PRAGMA busy_timeout = 5000") + # cc-switch checks that .db files have 0600 permissions on Unix. + if sys.platform != "win32": + paths.db_path.chmod(0o600) return conn