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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 From c354c149f4ff7a69300f8e968eb88d3da87f73b9 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 12 Jun 2026 06:43:14 +0800 Subject: [PATCH 18/18] fix: harden config storage permissions --- src-tauri/src/codex_history_migration.rs | 228 +++++- src-tauri/src/config.rs | 752 ++++++++++++++++-- src-tauri/src/database/backup.rs | 95 ++- src-tauri/src/database/dao/usage_rollup.rs | 82 +- src-tauri/src/database/mod.rs | 228 +++++- src-tauri/src/database/schema.rs | 81 +- src-tauri/src/database/tests.rs | 297 ++++++- src-tauri/src/hermes_config.rs | 4 +- src-tauri/src/openclaw_config.rs | 4 +- src-tauri/src/proxy/usage/logger.rs | 5 +- src-tauri/src/services/env_manager.rs | 53 +- .../provider/codex_openai_auth_tests.rs | 48 +- src-tauri/src/services/skill.rs | 4 +- src-tauri/src/services/webdav_sync/mod.rs | 286 ++++++- src-tauri/src/settings.rs | 10 +- src-tauri/tests/openclaw_config.rs | 4 + .../proxy_claude_openai_chat/error_cases.rs | 7 +- .../proxy_claude_openai_chat/logging_cases.rs | 24 +- .../proxy_claude_streaming/logging_cases.rs | 19 +- src-tauri/tests/settings_current_provider.rs | 15 +- src-tauri/tests/settings_visible_apps.rs | 15 +- src-tauri/tests/webdav_settings.rs | 53 +- 22 files changed, 2070 insertions(+), 244 deletions(-) diff --git a/src-tauri/src/codex_history_migration.rs b/src-tauri/src/codex_history_migration.rs index a37ddf11..9d109956 100644 --- a/src-tauri/src/codex_history_migration.rs +++ b/src-tauri/src/codex_history_migration.rs @@ -6,14 +6,16 @@ use crate::codex_config::{ get_codex_config_dir, read_codex_config_text, CC_SWITCH_CODEX_MODEL_PROVIDER_ID, }; -use crate::config::{atomic_write, copy_file, get_app_config_dir}; +use crate::config::{ + atomic_write, copy_file, create_managed_config_parent_dirs, get_app_config_dir, +}; use crate::database::{is_official_seed_id, Database}; use crate::error::AppError; use crate::settings::{ CodexProviderTemplateMigration, CodexThirdPartyHistoryProviderBucketMigration, }; use chrono::{Local, Utc}; -use rusqlite::{backup::Backup, params_from_iter, Connection}; +use rusqlite::{backup::Backup, params_from_iter, Connection, OpenFlags}; use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashSet}; @@ -732,12 +734,42 @@ fn backup_codex_state_db( let backup_path = backup_root .join("state") .join(relative_backup_path(db_path, codex_dir)); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + create_managed_config_parent_dirs(&backup_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::symlink_metadata(&backup_path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份文件不能是符号链接: {}", + backup_path.display() + ))); + } + Ok(meta) if meta.is_file() => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份文件已存在: {}", + backup_path.display() + ))); + } + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份路径不是普通文件: {}", + backup_path.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&backup_path) + .map_err(|e| AppError::io(&backup_path, e))?; + } + Err(err) => return Err(AppError::io(&backup_path, err)), + } } - let mut backup_conn = Connection::open(&backup_path) - .map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}")))?; + let mut backup_conn = open_codex_state_backup_connection(&backup_path)?; let backup = Backup::new(source_conn, &mut backup_conn) .map_err(|e| AppError::Database(format!("初始化 Codex state DB 备份失败: {e}")))?; backup @@ -746,6 +778,31 @@ fn backup_codex_state_db( Ok(()) } +fn open_codex_state_backup_connection(backup_path: &Path) -> Result { + let open_path = canonicalize_existing_parent(backup_path)?; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + + Connection::open_with_flags(&open_path, flags) + .map_err(|e| AppError::Database(format!("创建 Codex state DB 备份失败: {e}"))) +} + +fn canonicalize_existing_parent(path: &Path) -> Result { + let Some(file_name) = path.file_name() else { + return Err(AppError::InvalidInput(format!( + "Codex state DB 备份路径缺少文件名: {}", + path.display() + ))); + }; + let parent = path + .parent() + .ok_or_else(|| AppError::InvalidInput(format!("无效路径: {}", path.display())))?; + let parent = parent.canonicalize().map_err(|e| AppError::io(parent, e))?; + Ok(parent.join(file_name)) +} + fn backup_provider_settings_config( provider_id: &str, settings_config: &Value, @@ -754,9 +811,7 @@ fn backup_provider_settings_config( let backup_path = backup_root .join("providers") .join(provider_settings_backup_filename(provider_id)); - if let Some(parent) = backup_path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } + create_managed_config_parent_dirs(&backup_path)?; let payload = serde_json::json!({ "providerId": provider_id, @@ -793,9 +848,7 @@ fn provider_settings_backup_filename(provider_id: &str) -> String { } fn copy_existing_file(source: &Path, target: &Path) -> Result<(), AppError> { - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } + create_managed_config_parent_dirs(target)?; copy_file(source, target) } @@ -1259,6 +1312,157 @@ base_url = "https://proxy.example/v1" ) .expect("count backed up source providers"); assert_eq!(backed_up_source_count, 2); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&backup_path) + .expect("metadata backup db") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + + #[cfg(unix)] + #[test] + fn codex_migration_backups_reject_parent_dir_config_path_before_creating_dirs() { + let dir = tempdir().expect("tempdir"); + let root = dir.path().join("config-root"); + fs::create_dir(&root).expect("create root"); + let backup_root = root.join("child").join("..").join("backups"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.join("child").join("..")); + } + + let codex_dir = dir.path().join(".codex"); + let session_dir = codex_dir.join("sessions"); + fs::create_dir_all(&session_dir).expect("create session dir"); + let jsonl = session_dir.join("session.jsonl"); + fs::write(&jsonl, "{}\n").expect("write jsonl"); + copy_existing_file(&jsonl, &backup_root.join("jsonl").join("session.jsonl")) + .expect_err("jsonl backup should reject invalid config dir"); + + backup_provider_settings_config( + "provider", + &serde_json::json!({ "config": "model_provider = \"custom\"" }), + &backup_root, + ) + .expect_err("provider backup should reject invalid config dir"); + + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("state db backup should reject invalid config dir"); + + assert!( + !root.join("child").exists(), + "backup helpers must not pre-create unvalidated path components" + ); + assert!( + !root.join("backups").exists(), + "backup helpers must not write to the normalized parent directory" + ); + } + + #[cfg(unix)] + #[test] + fn codex_state_db_backup_rejects_symlink_backup_path_without_writing_target() { + use std::os::unix::fs::symlink; + + let dir = tempdir().expect("tempdir"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + + let backup_root = get_app_config_dir().join("backups").join("migration"); + let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); + fs::create_dir_all(backup_path.parent().expect("backup parent")) + .expect("create backup parent"); + let external_target = dir.path().join("external-state.sqlite"); + symlink(&external_target, &backup_path).expect("create dangling backup symlink"); + + let err = backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("state db backup must reject symlink backup path"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_target.exists(), + "state db backup must not follow symlink target" + ); + } + + #[cfg(unix)] + #[test] + fn codex_state_db_backup_rejects_existing_backup_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().expect("tempdir"); + let _env = crate::test_support::TestEnvGuard::isolated(dir.path()); + + let codex_dir = dir.path().join(".codex"); + fs::create_dir_all(&codex_dir).expect("create codex dir"); + let state_db = codex_dir.join(CODEX_STATE_DB_FILENAME); + let conn = Connection::open(&state_db).expect("open state db"); + conn.execute_batch( + "CREATE TABLE threads ( + id TEXT PRIMARY KEY, + model_provider TEXT NOT NULL + );", + ) + .expect("seed state db"); + + let backup_root = get_app_config_dir().join("backups").join("migration"); + let backup_path = backup_root.join("state").join(CODEX_STATE_DB_FILENAME); + fs::create_dir_all(backup_path.parent().expect("backup parent")) + .expect("create backup parent"); + fs::write(&backup_path, b"existing").expect("write existing backup"); + fs::set_permissions(&backup_path, fs::Permissions::from_mode(0o644)) + .expect("set existing backup permissions"); + + let err = backup_codex_state_db(&state_db, &codex_dir, &backup_root, &conn) + .expect_err("existing state db backup path must be rejected"); + + assert!( + err.to_string().contains("已存在") || err.to_string().contains("exists"), + "unexpected error: {err}" + ); + assert_eq!( + fs::read(&backup_path).expect("read existing backup"), + b"existing", + "state db backup must not overwrite an existing backup file" + ); + let mode = fs::metadata(&backup_path) + .expect("metadata existing backup") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o644, + "rejected existing backup path should be left untouched" + ); } #[test] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 21412249..ca3c6ea7 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::cli::i18n::texts; use crate::error::AppError; @@ -101,7 +101,7 @@ pub fn get_app_config_dir() -> PathBuf { /// 未设置环境变量时默认路径 `~/.cc-switch` 始终安全,直接放行。 pub fn validate_config_dir() -> Result<(), AppError> { let path = get_app_config_dir(); - let resolved = resolve_existing_or_new_child_path(&path)?; + let resolved = resolve_config_dir_without_following_user_symlinks(&path)?; if is_system_dir(&path) || is_system_dir(&resolved) { return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( @@ -114,8 +114,26 @@ pub fn validate_config_dir() -> Result<(), AppError> { } pub(crate) fn resolve_existing_or_new_child_path(path: &Path) -> Result { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含父目录组件: {}", + path.display() + ))); + } + match path.canonicalize() { - Ok(resolved) => Ok(resolved), + Ok(resolved) => { + if is_system_dir(path) || is_system_dir(&resolved) { + return Err(AppError::InvalidInput(texts::config_dir_is_system_dir( + &path.display().to_string(), + &resolved.display().to_string(), + ))); + } + Ok(resolved) + } Err(original_err) => { let file_name = path.file_name().ok_or_else(|| { AppError::InvalidInput(texts::config_dir_invalid_last_component( @@ -210,7 +228,13 @@ pub fn get_app_config_path() -> PathBuf { #[cfg(unix)] pub(crate) fn restrict_dir_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; - let meta = fs::metadata(path)?; + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is a symlink", + )); + } if !meta.is_dir() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -234,7 +258,13 @@ pub(crate) fn restrict_dir_permissions(_path: &Path) -> std::io::Result<()> { #[cfg(unix)] pub(crate) fn restrict_file_permissions(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; - let meta = fs::metadata(path)?; + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "path is a symlink", + )); + } if !meta.is_file() { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -259,30 +289,19 @@ pub(crate) fn restrict_file_permissions(_path: &Path) -> std::io::Result<()> { /// 返回不安全的路径列表:`(路径, 当前权限, 期望权限)` #[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 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 let Err(err) = resolve_config_dir_without_following_user_symlinks(&config_dir) { + log::warn!("跳过配置目录权限扫描:配置目录校验失败: {err}"); + return issues; } + let backup_dir = config_dir.join("backups"); - 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)); - } - } - } + collect_dir_permission_issue(&config_dir, &mut issues); + collect_dir_permission_issue(&backup_dir, &mut issues); - collect_sensitive_file_permission_issues(&config_dir, &mut issues); + collect_root_sensitive_file_permission_issues(&config_dir, &mut issues); + collect_sensitive_file_permission_issues(&backup_dir, &mut issues); issues } @@ -293,9 +312,61 @@ pub fn check_permissions() -> Vec<(PathBuf, u32, u32)> { } #[cfg(unix)] -fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { +fn collect_dir_permission_issue(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { use std::os::unix::fs::PermissionsExt; + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + + let mode = meta.permissions().mode() & 0o777; + if mode != 0o700 { + issues.push((dir.to_path_buf(), mode, 0o700)); + } +} + +#[cfg(unix)] +fn collect_root_sensitive_file_permission_issues( + dir: &Path, + issues: &mut Vec<(PathBuf, u32, u32)>, +) { + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + + 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_file() && is_sensitive_config_file(&path) { + collect_sensitive_file_permission_issue(&path, issues); + } + } +} + +#[cfg(unix)] +fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + let Ok(meta) = fs::symlink_metadata(dir) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_dir() { + return; + } + let Ok(entries) = fs::read_dir(dir) else { return; }; @@ -312,17 +383,28 @@ fn collect_sensitive_file_permission_issues(dir: &Path, issues: &mut Vec<(PathBu 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)); - } + collect_sensitive_file_permission_issue(&path, issues); } } } +#[cfg(unix)] +fn collect_sensitive_file_permission_issue(path: &Path, issues: &mut Vec<(PathBuf, u32, u32)>) { + use std::os::unix::fs::PermissionsExt; + + let Ok(meta) = fs::symlink_metadata(path) else { + return; + }; + if meta.file_type().is_symlink() || !meta.is_file() { + return; + } + + let mode = meta.permissions().mode() & 0o777; + if is_insecure_sensitive_file_mode(mode) { + issues.push((path.to_path_buf(), mode, 0o600)); + } +} + #[cfg(unix)] fn is_sensitive_config_file(path: &Path) -> bool { path.extension() @@ -425,6 +507,8 @@ fn write_permissions_noninteractive_warning( /// - 交互终端:使用 inquire 提示用户,确认后修复,拒绝则警告 /// - 非交互终端(Docker/管道):仅打印警告到 stderr pub fn prompt_fix_permissions() -> Result<(), AppError> { + validate_config_dir()?; + let issues = check_permissions(); if issues.is_empty() { return Ok(()); @@ -482,11 +566,6 @@ pub fn read_json_file Deserialize<'a>>(path: &Path) -> Result(path: &Path, data: &T) -> Result<(), AppError> { - // 确保目录存在 - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - let json = serde_json::to_string_pretty(data).map_err(|e| AppError::JsonSerialize { source: e })?; @@ -495,23 +574,29 @@ pub fn write_json_file(path: &Path, data: &T) -> Result<(), AppErr /// 原子写入文本文件(用于 TOML/纯文本) pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } atomic_write(path, data.as_bytes()) } /// 原子写入:写入临时文件后 rename 替换,避免半写状态 pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { + let managed_write_path = resolve_managed_storage_path(path)?; + let should_restrict_file = should_restrict_sensitive_config_file(path)?; + let write_path = managed_write_path.unwrap_or_else(|| path.to_path_buf()); + if path != write_path && should_restrict_file { + debug_assert!(write_path.is_absolute()); + } + + if path.starts_with(get_app_config_dir()) { + create_managed_config_parent_dirs(path)?; + } else if let Some(parent) = write_path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } - let parent = path + let parent = write_path .parent() .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; let mut tmp = parent.to_path_buf(); - let file_name = path + let file_name = write_path .file_name() .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() @@ -523,7 +608,22 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { tmp.push(format!("{file_name}.tmp.{ts}")); { + #[cfg(unix)] + let mut f = if should_restrict_file { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&tmp) + .map_err(|e| AppError::io(&tmp, e))? + } else { + fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))? + }; + + #[cfg(not(unix))] let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; + f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; f.flush().map_err(|e| AppError::io(&tmp, e))?; } @@ -531,7 +631,9 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(path) { + if should_restrict_file { + restrict_file_permissions(&tmp).map_err(|e| AppError::io(&tmp, e))?; + } else if let Ok(meta) = fs::metadata(&write_path) { let perm = meta.permissions().mode(); let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); } @@ -540,25 +642,314 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { #[cfg(windows)] { // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) - if path.exists() { - let _ = fs::remove_file(path); + if write_path.exists() { + let _ = fs::remove_file(&write_path); } - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + fs::rename(&tmp, &write_path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + tmp.display(), + write_path.display() + ), source: e, })?; } #[cfg(not(windows))] { - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), + fs::rename(&tmp, &write_path).map_err(|e| AppError::IoContext { + context: format!( + "原子替换失败: {} -> {}", + tmp.display(), + write_path.display() + ), source: e, })?; } + if should_restrict_file { + restrict_file_permissions(&write_path).map_err(|e| AppError::io(&write_path, e))?; + } Ok(()) } +fn should_restrict_sensitive_config_file(path: &Path) -> Result { + #[cfg(unix)] + { + if !is_sensitive_config_file(path) { + return Ok(false); + } + + is_managed_sensitive_config_path(path) + } + + #[cfg(not(unix))] + { + let _ = path; + Ok(false) + } +} + +pub(crate) fn resolve_managed_storage_path(path: &Path) -> Result, AppError> { + let raw_root = get_app_config_dir(); + if !path.starts_with(&raw_root) { + return Ok(None); + } + + let resolved_root = resolve_config_dir_without_following_user_symlinks(&raw_root)?; + let suffix = path + .strip_prefix(&raw_root) + .unwrap_or_else(|_| Path::new("")); + validate_managed_storage_suffix(suffix, path)?; + Ok(Some(resolved_root.join(suffix))) +} + +fn validate_managed_storage_suffix(suffix: &Path, original_path: &Path) -> Result<(), AppError> { + if suffix + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(AppError::InvalidInput(format!( + "受管配置路径不能包含父目录组件: {}", + original_path.display() + ))); + } + + Ok(()) +} + +pub(crate) fn resolve_config_dir_without_following_user_symlinks( + path: &Path, +) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .map_err(|e| AppError::io(".", e))? + .join(path) + }; + let mut current = PathBuf::new(); + let components = absolute.components().collect::>(); + + for (idx, component) in components.iter().enumerate() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => { + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含父目录组件: {}", + path.display() + ))); + } + Component::Normal(part) => { + current.push(part); + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if is_allowed_platform_config_symlink(¤t) { + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if idx + 1 != components.len() { + return Err(AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: err, + }); + } + break; + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } + } + } + + resolve_existing_or_new_child_path(¤t) +} + +pub(crate) fn create_managed_config_parent_dirs(path: &Path) -> Result<(), AppError> { + if let Some(resolved) = resolve_managed_storage_path(path)? { + if let Some(parent) = resolved.parent() { + #[cfg(unix)] + { + let config_root = + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())?; + create_secure_config_dir_all_no_symlink(&config_root, parent)?; + } + + #[cfg(not(unix))] + { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + } + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + Ok(()) +} + +pub(crate) fn create_managed_config_dir_all(path: &Path) -> Result<(), AppError> { + if let Some(resolved) = resolve_managed_storage_path(path)? { + #[cfg(unix)] + { + let config_root = + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())?; + create_secure_config_dir_all_no_symlink(&config_root, &resolved)?; + } + + #[cfg(not(unix))] + { + fs::create_dir_all(&resolved).map_err(|e| AppError::io(&resolved, e))?; + } + + return Ok(()); + } + + fs::create_dir_all(path).map_err(|e| AppError::io(path, e))?; + Ok(()) +} + +#[cfg(unix)] +fn create_secure_config_dir_all_no_symlink( + config_root: &Path, + path: &Path, +) -> Result<(), AppError> { + use std::os::unix::fs::DirBuilderExt; + + let mut current = PathBuf::new(); + let mut managed_component = false; + for component in path.components() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => unreachable!("paths are resolved before secure creation"), + Component::Normal(part) => { + current.push(part); + if current == config_root { + managed_component = true; + } + match fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if is_allowed_platform_config_symlink(¤t) { + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(meta) if meta.is_dir() => { + if managed_component { + restrict_dir_permissions(¤t) + .map_err(|e| AppError::io(¤t, e))?; + } + } + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "配置目录路径组件不是目录: {}", + current.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + if !managed_component { + return Err(AppError::IoContext { + context: texts::config_dir_only_final_component_may_be_missing( + &path.display().to_string(), + ), + source: err, + }); + } + fs::DirBuilder::new() + .mode(0o700) + .create(¤t) + .map_err(|e| AppError::io(¤t, e))?; + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } + } + } + + Ok(()) +} + +#[cfg(unix)] +fn is_allowed_platform_config_symlink(path: &Path) -> bool { + #[cfg(target_os = "macos")] + { + matches!(path.to_str(), Some("/tmp") | Some("/var") | Some("/etc")) + } + + #[cfg(not(target_os = "macos"))] + { + let _ = path; + false + } +} + +#[cfg(unix)] +fn is_managed_sensitive_config_path(path: &Path) -> Result { + let config_dir = normalized_absolute_path(&get_app_config_dir())?; + let path = normalized_absolute_path(path)?; + if !path.starts_with(&config_dir) { + return Ok(false); + } + + let Ok(relative) = path.strip_prefix(&config_dir) else { + return Ok(false); + }; + + let components = relative.components().collect::>(); + if components.len() == 1 { + return Ok(true); + } + + Ok(matches!( + components.first(), + Some(Component::Normal(name)) if *name == "backups" + )) +} + +#[cfg(unix)] +fn normalized_absolute_path(path: &Path) -> Result { + let base = if path.is_absolute() { + PathBuf::new() + } else { + env::current_dir().map_err(|e| AppError::io(".", e))? + }; + + let mut normalized = PathBuf::new(); + for component in base.join(path).components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(AppError::InvalidInput(format!( + "路径包含无效的父目录组件: {}", + path.display() + ))); + } + } + Component::Normal(part) => normalized.push(part), + } + } + + Ok(normalized) +} + #[cfg(test)] mod tests { use super::*; @@ -832,7 +1223,7 @@ mod tests { } #[test] - fn validate_config_dir_allows_parent_dir_components_when_parent_resolves() { + fn validate_config_dir_rejects_parent_dir_components_even_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"); @@ -842,13 +1233,13 @@ mod tests { 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") + assert!( + validate_config_dir().is_err(), + "config dir should reject parent components instead of normalizing to the parent" + ); + assert!( + resolve_config_dir_without_following_user_symlinks(&config_dir).is_err(), + "managed config root resolution must reject parent components" ); } @@ -872,6 +1263,51 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn validate_and_permission_checks_reject_symlink_parent_without_touching_target() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let external_parent = temp.path().join("external"); + let external_config = external_parent.join(".cc-switch"); + let link_parent = temp.path().join("link"); + std::fs::create_dir(&external_parent).expect("create external parent"); + std::fs::create_dir(&external_config).expect("create external config"); + std::fs::set_permissions(&external_config, fs::Permissions::from_mode(0o755)) + .expect("set insecure external config perms"); + symlink(&external_parent, &link_parent).expect("create symlink parent"); + + let raw_config = link_parent.join(".cc-switch"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(raw_config.to_str().unwrap())); + + assert!( + validate_config_dir().is_err(), + "validation should reject the symlink parent component" + ); + assert!( + check_permissions().is_empty(), + "permission scan should not follow rejected symlink parents" + ); + + let err = prompt_fix_permissions().expect_err("prompt should fail before chmod"); + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + let mode = std::fs::metadata(&external_config) + .expect("metadata external config") + .permissions() + .mode() + & 0o777; + assert_eq!( + mode, 0o755, + "prompt must not chmod the symlink target before DB init rejects it" + ); + } + #[cfg(unix)] #[test] fn check_permissions_returns_empty_for_secure_permissions() { @@ -1043,6 +1479,34 @@ mod tests { assert!(issue_paths.contains(&(nested_db, 0o644, 0o600))); } + #[cfg(unix)] + #[test] + fn check_permissions_ignores_skill_json_metadata() { + 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 skill_dir = temp.path().join("skills").join("demo-skill"); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + let plugin_json = skill_dir.join("plugin.json"); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .mode(0o644) + .open(&plugin_json) + .expect("create skill metadata"); + + assert!( + check_permissions().is_empty(), + "skill metadata JSON should not be treated as cc-switch secret state" + ); + } + #[cfg(unix)] #[test] fn check_permissions_allows_more_restrictive_sensitive_file_permissions() { @@ -1232,6 +1696,178 @@ mod tests { assert_eq!(prompter.custom_dir_calls, 1); assert_eq!(prompter.fix_calls, 0); } + + #[cfg(unix)] + #[test] + fn write_json_file_restricts_sensitive_files_under_cc_switch_config_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 path = temp.path().join("config.json"); + write_json_file(&path, &serde_json::json!({ "token": "secret" })) + .expect("write sensitive json"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + let dir_mode = std::fs::metadata(temp.path()) + .expect("metadata config dir") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert_eq!(dir_mode, 0o700); + assert!(check_permissions().is_empty()); + } + + #[cfg(target_os = "macos")] + #[test] + fn write_json_file_restricts_sensitive_files_under_macos_tmp_alias() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let temp = tempfile::tempdir_in("/tmp").expect("create /tmp temp dir"); + let config_dir = temp.path().join("cc-switch"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + let path = get_app_config_dir().join("settings.json"); + write_json_file(&path, &serde_json::json!({ "token": "secret" })) + .expect("write sensitive json"); + + let mode = std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_sensitive_file_under_symlinked_config_subdir() { + use std::os::unix::fs::{symlink, PermissionsExt}; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + let config_dir = root.path().join("cc-switch"); + let external_dir = root.path().join("external-backups"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::create_dir(&external_dir).expect("create external dir"); + std::fs::set_permissions(&config_dir, fs::Permissions::from_mode(0o700)) + .expect("set config dir perms"); + std::fs::set_permissions(&external_dir, fs::Permissions::from_mode(0o755)) + .expect("set external dir perms"); + symlink(&external_dir, config_dir.join("backups")).expect("create backups symlink"); + + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + let err = write_json_file( + &config_dir.join("backups").join("secret.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("sensitive writes must reject symlinked config subdirs"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + let external_mode = std::fs::metadata(&external_dir) + .expect("metadata external dir") + .permissions() + .mode() + & 0o777; + assert_eq!( + external_mode, 0o755, + "symlink target permissions must not be modified" + ); + assert!( + !external_dir.join("secret.json").exists(), + "write should not follow symlink and create the sensitive file outside config dir" + ); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_unresolved_parent_dir_components_without_chmodding_parent() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + std::fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)) + .expect("set root perms"); + let config_dir = root.path().join("child").join(".."); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + write_json_file( + &get_app_config_dir().join("settings.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("unresolved parent components should be rejected before chmod"); + + let root_mode = std::fs::metadata(root.path()) + .expect("metadata root") + .permissions() + .mode() + & 0o777; + assert_eq!( + root_mode, 0o755, + "invalid config dir writes must not chmod the resolved parent" + ); + assert!( + !root.path().join("settings.json").exists(), + "invalid config dir write should not create the normalized target" + ); + } + + #[cfg(unix)] + #[test] + fn write_json_file_rejects_symlink_parent_even_when_followed_by_dotdot() { + use std::os::unix::fs::symlink; + + let _guard = lock_test_home_and_settings(); + let root = tempfile::tempdir().expect("create temp dir"); + let real_parent = root.path().join("real-parent"); + let external_parent = root.path().join("external-parent"); + std::fs::create_dir(&real_parent).expect("create real parent"); + std::fs::create_dir(&external_parent).expect("create external parent"); + symlink(&external_parent, real_parent.join("link")).expect("create symlink parent"); + + let config_dir = real_parent.join("link").join("..").join("cc-switch"); + let _env = + ConfigDirEnvGuard::new("CC_SWITCH_CONFIG_DIR", Some(config_dir.to_str().unwrap())); + + let err = write_json_file( + &get_app_config_dir().join("settings.json"), + &serde_json::json!({ "token": "secret" }), + ) + .expect_err("symlink component should be rejected before lexical dotdot collapse"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !real_parent.join("cc-switch/settings.json").exists(), + "rejected write must not create the normalized target" + ); + assert!( + !external_parent.join("cc-switch/settings.json").exists(), + "write must not follow the symlinked parent from the raw path" + ); + } } /// 复制文件 diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 5a08b726..5688bf91 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -2,13 +2,12 @@ //! //! 提供 SQL 导出/导入和二进制快照备份功能。 -use super::{create_secure_dir_all, lock_conn, Database, DB_BACKUP_RETAIN}; -use crate::config::get_app_config_dir; +use super::{create_secure_dir_all, database_path, lock_conn, Database, DB_BACKUP_RETAIN}; use crate::error::AppError; use chrono::Utc; use rusqlite::backup::Backup; use rusqlite::types::Value; -use rusqlite::{Connection, OptionalExtension}; +use rusqlite::{Connection, OpenFlags, OptionalExtension}; use std::fs; use std::path::{Path, PathBuf}; use tempfile::NamedTempFile; @@ -470,7 +469,7 @@ impl Database { /// 生成一致性快照备份,返回备份文件路径(不存在主库时返回 None) pub(crate) fn backup_database_file(&self) -> Result, AppError> { - let db_path = get_app_config_dir().join("cc-switch.db"); + let db_path = database_path()?; if !db_path.exists() { return Ok(None); } @@ -492,26 +491,9 @@ 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 = - Connection::open(&backup_path).map_err(|e| AppError::Database(e.to_string()))?; + let mut dest_conn = Self::create_backup_db_connection(&backup_path)?; let backup = Backup::new(&conn, &mut dest_conn) .map_err(|e| AppError::Database(e.to_string()))?; backup @@ -523,6 +505,70 @@ impl Database { Ok(Some(backup_path)) } + pub(super) fn create_backup_db_connection(backup_path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + match std::fs::symlink_metadata(backup_path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(AppError::InvalidInput(format!( + "数据库备份文件不能是符号链接: {}", + backup_path.display() + ))); + } + Ok(meta) if meta.is_file() => { + return Err(AppError::InvalidInput(format!( + "数据库备份文件已存在: {}", + backup_path.display() + ))); + } + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "数据库备份路径不是普通文件: {}", + backup_path.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(backup_path) + .map_err(|e| AppError::io(backup_path, e))?; + } + Err(err) => return Err(AppError::io(backup_path, err)), + } + + let open_path = Self::canonicalize_existing_parent(backup_path)?; + let flags = OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW; + Connection::open_with_flags(&open_path, flags) + .map_err(|e| AppError::Database(e.to_string())) + } + + #[cfg(not(unix))] + { + std::fs::File::create(backup_path).map_err(|e| AppError::io(backup_path, e))?; + Connection::open(backup_path).map_err(|e| AppError::Database(e.to_string())) + } + } + + fn canonicalize_existing_parent(path: &Path) -> Result { + let Some(file_name) = path.file_name() else { + return Err(AppError::InvalidInput(format!( + "数据库备份路径缺少文件名: {}", + path.display() + ))); + }; + let parent = path + .parent() + .ok_or_else(|| AppError::InvalidInput(format!("无效路径: {}", path.display())))?; + let parent = parent.canonicalize().map_err(|e| AppError::io(parent, e))?; + Ok(parent.join(file_name)) + } + /// 清理旧的数据库备份,保留最新的 N 个 fn cleanup_db_backups(dir: &Path) -> Result<(), AppError> { let entries = match fs::read_dir(dir) { @@ -736,10 +782,7 @@ impl Database { return Ok(true); }; - Ok(!policy - .local_settings_keys - .iter() - .any(|local_key| *local_key == key)) + Ok(!policy.local_settings_keys.contains(&key)) } fn neutralize_export_row( diff --git a/src-tauri/src/database/dao/usage_rollup.rs b/src-tauri/src/database/dao/usage_rollup.rs index 114da3d3..5dbc6a31 100644 --- a/src-tauri/src/database/dao/usage_rollup.rs +++ b/src-tauri/src/database/dao/usage_rollup.rs @@ -108,13 +108,13 @@ impl Database { let effective_filter = effective_usage_log_filter("l"); let aggregation_sql = format!( "INSERT OR REPLACE INTO usage_daily_rollups - (date, app_type, provider_id, model, + (date, app_type, provider_id, model, request_model, pricing_model, request_count, success_count, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) SELECT - d, a, p, m, + d, a, p, m, rm, pm, COALESCE(old.request_count, 0) + new_req, COALESCE(old.success_count, 0) + new_succ, COALESCE(old.input_tokens, 0) + new_in, @@ -131,6 +131,8 @@ impl Database { SELECT date(l.created_at, 'unixepoch', 'localtime') as d, l.app_type as a, l.provider_id as p, l.model as m, + COALESCE(l.request_model, '') as rm, + COALESCE(l.pricing_model, '') as pm, COUNT(*) as new_req, SUM(CASE WHEN l.status_code >= 200 AND l.status_code < 300 THEN 1 ELSE 0 END) as new_succ, COALESCE(SUM(l.input_tokens), 0) as new_in, @@ -141,11 +143,12 @@ impl Database { COALESCE(AVG(l.latency_ms), 0) as new_lat FROM proxy_request_logs l WHERE l.created_at < ?1 AND {effective_filter} - GROUP BY d, a, p, m + GROUP BY d, a, p, m, rm, pm ) agg LEFT JOIN usage_daily_rollups old ON old.date = agg.d AND old.app_type = agg.a - AND old.provider_id = agg.p AND old.model = agg.m" + AND old.provider_id = agg.p AND old.model = agg.m + AND old.request_model = agg.rm AND old.pricing_model = agg.pm" ); conn.execute(&aggregation_sql, [cutoff]) @@ -332,6 +335,77 @@ mod tests { Ok(()) } + #[test] + fn test_rollup_preserves_request_and_pricing_model_dimensions() -> Result<(), AppError> { + let db = Database::memory()?; + let now = chrono::Utc::now().timestamp(); + let old_ts = now - 40 * 86400; + + { + let conn = crate::database::lock_conn!(db.conn); + for (request_id, request_model, pricing_model, cost) in [ + ("dim-a", "claude-sonnet-4", "claude-sonnet-4", "0.10"), + ("dim-b", "claude-haiku-4", "claude-haiku-4", "0.01"), + ("dim-c", "claude-sonnet-4", "kimi-k2", "0.02"), + ] { + conn.execute( + "INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, pricing_model, + input_tokens, output_tokens, total_cost_usd, + latency_ms, status_code, created_at + ) VALUES (?1, 'p1', 'claude', 'kimi-k2', ?2, ?3, 100, 50, ?4, 200, 200, ?5)", + rusqlite::params![request_id, request_model, pricing_model, cost, old_ts], + )?; + } + } + + let deleted = db.rollup_and_prune(30)?; + assert_eq!(deleted, 3); + + let conn = crate::database::lock_conn!(db.conn); + let mut stmt = conn.prepare( + "SELECT request_model, pricing_model, request_count, total_cost_usd + FROM usage_daily_rollups + WHERE model = 'kimi-k2' + ORDER BY request_model, pricing_model", + )?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) + })? + .collect::, _>>()?; + + assert_eq!( + rows, + vec![ + ( + "claude-haiku-4".to_string(), + "claude-haiku-4".to_string(), + 1, + "0.01".to_string(), + ), + ( + "claude-sonnet-4".to_string(), + "claude-sonnet-4".to_string(), + 1, + "0.1".to_string(), + ), + ( + "claude-sonnet-4".to_string(), + "kimi-k2".to_string(), + 1, + "0.02".to_string(), + ), + ] + ); + Ok(()) + } + #[test] fn test_rollup_merges_with_existing() -> Result<(), AppError> { let db = Database::memory()?; diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 5fbbaa5c..51ffa445 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -37,12 +37,13 @@ pub(crate) use dao::providers_seed::is_official_seed_id; pub use dao::FailoverQueueItem; use crate::config::{ - get_app_config_dir, resolve_existing_or_new_child_path, restrict_dir_permissions, + get_app_config_dir, resolve_config_dir_without_following_user_symlinks, + resolve_existing_or_new_child_path, }; use crate::error::AppError; use rusqlite::{Connection, OpenFlags}; use serde::Serialize; -use std::path::Path; +use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, Once}; use std::time::Duration; @@ -58,7 +59,60 @@ static DATABASE_PERMISSION_CHECK: Once = Once::new(); /// 当前 Schema 版本号 /// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑 -pub(crate) const SCHEMA_VERSION: i32 = 10; +pub(crate) const SCHEMA_VERSION: i32 = 11; + +fn database_open_flags() -> OpenFlags { + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW +} + +fn readonly_database_open_flags() -> OpenFlags { + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_NO_MUTEX + | OpenFlags::SQLITE_OPEN_NOFOLLOW +} + +pub(crate) fn database_path() -> Result { + Ok( + resolve_config_dir_without_following_user_symlinks(&get_app_config_dir())? + .join("cc-switch.db"), + ) +} + +#[cfg(unix)] +fn reject_hardlinked_database_file(path: &Path, meta: &std::fs::Metadata) -> Result<(), AppError> { + use std::os::unix::fs::MetadataExt; + + if meta.nlink() > 1 { + return Err(AppError::InvalidInput(format!( + "数据库文件不能有多个硬链接: {}", + path.display() + ))); + } + + Ok(()) +} + +#[cfg(unix)] +fn validate_existing_database_file(path: &Path) -> Result<(), AppError> { + let meta = std::fs::symlink_metadata(path).map_err(|e| AppError::io(path, e))?; + if meta.file_type().is_symlink() { + return Err(AppError::InvalidInput(format!( + "数据库文件不能是符号链接: {}", + path.display() + ))); + } + if !meta.is_file() { + return Err(AppError::InvalidInput(format!( + "数据库路径不是普通文件: {}", + path.display() + ))); + } + + reject_hardlinked_database_file(path, &meta) +} /// 安全地序列化 JSON,避免 unwrap panic pub(crate) fn to_json_string(value: &T) -> Result { @@ -66,49 +120,130 @@ 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) -} + let path = resolve_create_dir_path(path)?; -fn create_secure_dir_all_resolved(path: &Path) -> Result { - if path.as_os_str().is_empty() || path.is_dir() { - return Ok(false); + #[cfg(unix)] + { + create_secure_dir_all_no_symlink(&path) } - if let Some(parent) = path.parent() { - if parent != path && !parent.as_os_str().is_empty() { - create_secure_dir_all(parent)?; + #[cfg(not(unix))] + { + match std::fs::create_dir_all(&path) { + Ok(()) => Ok(true), + Err(err) => Err(AppError::io(&path, err)), } } +} - #[cfg(unix)] - let create_result = { - use std::os::unix::fs::DirBuilderExt; +fn resolve_create_dir_path(path: &Path) -> Result { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + resolve_existing_or_new_child_path(path)?; + return normalize_path_lexically(path); + } - std::fs::DirBuilder::new().mode(0o700).create(path) + Ok(path.to_path_buf()) +} + +fn normalize_path_lexically(path: &Path) -> Result { + let base = if path.is_absolute() { + PathBuf::new() + } else { + std::env::current_dir().map_err(|e| AppError::io(".", e))? }; - #[cfg(not(unix))] - let create_result = std::fs::DirBuilder::new().create(path); + let mut normalized = PathBuf::new(); + for component in base.join(path).components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(AppError::InvalidInput(format!( + "路径包含无效的父目录组件: {}", + path.display() + ))); + } + } + Component::Normal(part) => normalized.push(part), + } + } - 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(normalized) +} - Ok(false) +#[cfg(unix)] +fn create_secure_dir_all_no_symlink(path: &Path) -> Result { + use std::os::unix::fs::DirBuilderExt; + + let mut current = PathBuf::new(); + let mut created_any = false; + + for component in path.components() { + match component { + Component::Prefix(prefix) => current.push(prefix.as_os_str()), + Component::RootDir => current.push(component.as_os_str()), + Component::CurDir => continue, + Component::ParentDir => unreachable!("parent components are rejected before creation"), + Component::Normal(part) => { + current.push(part); + match std::fs::symlink_metadata(¤t) { + Ok(meta) if meta.file_type().is_symlink() => { + if let Some(resolved) = allowed_platform_symlink_component(¤t)? { + current = resolved; + continue; + } + return Err(AppError::InvalidInput(format!( + "配置目录路径不能包含符号链接: {}", + current.display() + ))); + } + Ok(meta) if meta.is_dir() => {} + Ok(_) => { + return Err(AppError::InvalidInput(format!( + "配置目录路径组件不是目录: {}", + current.display() + ))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::DirBuilder::new() + .mode(0o700) + .create(¤t) + .map_err(|e| AppError::io(¤t, e))?; + created_any = true; + } + Err(err) => return Err(AppError::io(¤t, err)), + } + } } - Err(err) => Err(AppError::io(path, err)), } + + Ok(created_any) +} + +#[cfg(unix)] +fn allowed_platform_symlink_component(path: &Path) -> Result, AppError> { + #[cfg(target_os = "macos")] + { + if matches!(path.to_str(), Some("/tmp") | Some("/var") | Some("/etc")) { + let resolved = path.canonicalize().map_err(|e| AppError::io(path, e))?; + let meta = std::fs::metadata(&resolved).map_err(|e| AppError::io(&resolved, e))?; + if meta.is_dir() { + return Ok(Some(resolved)); + } + } + } + + let _ = path; + Ok(None) } /// 安全地获取 Mutex 锁,避免 unwrap panic @@ -151,7 +286,7 @@ impl Database { } warn_insecure_permissions_once(); - let db_path = get_app_config_dir().join("cc-switch.db"); + let db_path = database_path()?; // 确保父目录存在 if let Some(parent) = db_path.parent() { @@ -162,13 +297,17 @@ impl Database { #[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))?; + match std::fs::symlink_metadata(&db_path) { + Ok(_) => validate_existing_database_file(&db_path)?, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&db_path) + .map_err(|e| AppError::io(&db_path, e))?; + } + Err(err) => return Err(AppError::io(&db_path, err)), } } #[cfg(not(unix))] @@ -178,7 +317,8 @@ impl Database { } } - let conn = Connection::open(&db_path).map_err(|e| AppError::Database(e.to_string()))?; + let conn = Connection::open_with_flags(&db_path, database_open_flags()) + .map_err(|e| AppError::Database(e.to_string()))?; Self::configure_connection(&conn)?; // 多进程并发:daemon 与 worker 都会打开这个文件,WAL + busy_timeout 让 @@ -222,15 +362,17 @@ impl Database { /// /// 用于 TUI 后台热刷新等只读路径;不会创建目录、建表、迁移、seed 或执行启动维护。 pub fn open_readonly_current_schema() -> Result { - let db_path = get_app_config_dir().join("cc-switch.db"); + let db_path = database_path()?; if !db_path.exists() { return Err(AppError::Database(format!( "database is not initialized: {}", db_path.display() ))); } + #[cfg(unix)] + validate_existing_database_file(&db_path)?; - let conn = Connection::open_with_flags(&db_path, OpenFlags::SQLITE_OPEN_READ_ONLY) + let conn = Connection::open_with_flags(&db_path, readonly_database_open_flags()) .map_err(|e| AppError::Database(e.to_string()))?; Self::configure_connection(&conn)?; diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs index a949cb7e..e4126297 100644 --- a/src-tauri/src/database/schema.rs +++ b/src-tauri/src/database/schema.rs @@ -174,9 +174,12 @@ impl Database { )", []).map_err(|e| AppError::Database(e.to_string()))?; // 10. Proxy Request Logs 表 + // pricing_model = 写入时实际用于计价的模型名;NULL 表示 v11 之前的历史行, + // '' 表示未计价的错误行。 conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, request_model TEXT, + pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', @@ -234,6 +237,8 @@ impl Database { app_type TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', request_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -242,7 +247,7 @@ impl Database { cache_creation_tokens INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model) + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) )", [], ) @@ -401,6 +406,13 @@ impl Database { Self::migrate_v9_to_v10(conn)?; Self::set_user_version(conn, 10)?; } + 10 => { + log::info!( + "迁移数据库从 v10 到 v11(usage_daily_rollups 保留请求/计价模型维度)" + ); + Self::migrate_v10_to_v11(conn)?; + Self::set_user_version(conn, 11)?; + } _ => { return Err(AppError::Database(format!( "未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}" @@ -574,6 +586,7 @@ impl Database { conn.execute("CREATE TABLE IF NOT EXISTS proxy_request_logs ( request_id TEXT PRIMARY KEY, provider_id TEXT NOT NULL, app_type TEXT NOT NULL, model TEXT NOT NULL, request_model TEXT, + pricing_model TEXT, input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0, cache_read_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_tokens INTEGER NOT NULL DEFAULT 0, input_cost_usd TEXT NOT NULL DEFAULT '0', output_cost_usd TEXT NOT NULL DEFAULT '0', @@ -1035,6 +1048,8 @@ impl Database { app_type TEXT NOT NULL, provider_id TEXT NOT NULL, model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', request_count INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, input_tokens INTEGER NOT NULL DEFAULT 0, @@ -1043,7 +1058,7 @@ impl Database { cache_creation_tokens INTEGER NOT NULL DEFAULT 0, total_cost_usd TEXT NOT NULL DEFAULT '0', avg_latency_ms INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (date, app_type, provider_id, model) + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) )", [], ) @@ -1212,6 +1227,68 @@ impl Database { Ok(()) } + /// v10 -> v11 迁移:保留请求模型与计价模型维度。 + /// + /// WebDAV 同步会在不同分支之间交换 SQLite 快照;上游 v11 已经把 + /// `usage_daily_rollups` 的主键扩展为 `(model, request_model, pricing_model)`。 + /// 本迁移让当前分支可以接收并继续维护这些快照,而不是把上游 v11 误判为 + /// future schema。 + fn migrate_v10_to_v11(conn: &Connection) -> Result<(), AppError> { + if Self::table_exists(conn, "proxy_request_logs")? { + Self::add_column_if_missing(conn, "proxy_request_logs", "pricing_model", "TEXT")?; + } + + if !Self::table_exists(conn, "usage_daily_rollups")? { + log::info!("v10 -> v11:usage_daily_rollups 不存在,跳过重建"); + return Ok(()); + } + + if Self::has_column(conn, "usage_daily_rollups", "request_model")? + && Self::has_column(conn, "usage_daily_rollups", "pricing_model")? + { + log::info!("v10 -> v11:usage_daily_rollups 已包含 v11 维度,跳过重建"); + return Ok(()); + } + + conn.execute_batch( + "ALTER TABLE usage_daily_rollups RENAME TO usage_daily_rollups_v10; + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT NOT NULL DEFAULT '', + pricing_model TEXT NOT NULL DEFAULT '', + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model, request_model, pricing_model) + ); + INSERT INTO usage_daily_rollups + (date, app_type, provider_id, model, request_model, pricing_model, + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms) + SELECT date, app_type, provider_id, model, '', '', + request_count, success_count, input_tokens, output_tokens, + cache_read_tokens, cache_creation_tokens, total_cost_usd, avg_latency_ms + FROM usage_daily_rollups_v10; + DROP TABLE usage_daily_rollups_v10;", + ) + .map_err(|e| { + AppError::Database(format!("v10 -> v11 重建 usage_daily_rollups 失败: {e}")) + })?; + + log::info!( + "v10 -> v11 迁移完成:usage_daily_rollups 已保留 request_model/pricing_model 维度" + ); + Ok(()) + } + /// 插入默认模型定价数据 /// 格式: (model_id, display_name, input, output, cache_read, cache_creation) /// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 56291b88..39a672b1 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -571,6 +571,22 @@ fn schema_create_tables_include_usage_daily_rollups() { Some("0") ); + let request_model = get_column_info(&conn, "usage_daily_rollups", "request_model"); + assert_eq!(request_model.r#type, "TEXT"); + assert_eq!(request_model.notnull, 1); + assert_eq!( + normalize_default(&request_model.default).as_deref(), + Some("") + ); + + let pricing_model = get_column_info(&conn, "usage_daily_rollups", "pricing_model"); + assert_eq!(pricing_model.r#type, "TEXT"); + assert_eq!(pricing_model.notnull, 1); + assert_eq!( + normalize_default(&pricing_model.default).as_deref(), + Some("") + ); + assert!( Database::table_exists(&conn, "session_log_sync").expect("check session_log_sync table"), "session_log_sync should exist after create_tables" @@ -593,6 +609,10 @@ fn schema_create_tables_include_usage_daily_rollups() { Some("proxy") ); + let request_pricing_model = get_column_info(&conn, "proxy_request_logs", "pricing_model"); + assert_eq!(request_pricing_model.r#type, "TEXT"); + assert_eq!(request_pricing_model.notnull, 0); + let mcp_enabled_hermes = get_column_info(&conn, "mcp_servers", "enabled_hermes"); assert_eq!(mcp_enabled_hermes.r#type, "BOOLEAN"); assert_eq!(mcp_enabled_hermes.notnull, 1); @@ -1081,7 +1101,7 @@ fn schema_migration_v7_adds_session_log_tracking_and_corrects_pricing() { } #[test] -fn schema_migration_v8_refreshes_model_pricing_and_reaches_v10() { +fn schema_migration_v8_refreshes_model_pricing_and_reaches_v11() { let conn = Connection::open_in_memory().expect("open memory db"); conn.execute_batch( r#" @@ -1233,6 +1253,112 @@ fn schema_migration_v8_refreshes_model_pricing_and_reaches_v10() { .expect("check skills enabled_hermes"), "v9 -> v10 should add enabled_hermes to skills" ); + assert!( + Database::has_column(&conn, "proxy_request_logs", "pricing_model") + .expect("check proxy_request_logs pricing_model"), + "v10 -> v11 should add pricing_model to proxy_request_logs" + ); + assert!( + Database::has_column(&conn, "usage_daily_rollups", "request_model") + .expect("check rollup request_model"), + "v10 -> v11 should add request_model to usage_daily_rollups" + ); + assert!( + Database::has_column(&conn, "usage_daily_rollups", "pricing_model") + .expect("check rollup pricing_model"), + "v10 -> v11 should add pricing_model to usage_daily_rollups" + ); +} + +#[test] +fn schema_migration_v10_to_v11_preserves_rollup_rows_with_empty_new_dimensions() { + let conn = Connection::open_in_memory().expect("open memory db"); + conn.execute_batch( + r#" + CREATE TABLE proxy_request_logs ( + request_id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + app_type TEXT NOT NULL, + model TEXT NOT NULL, + request_model TEXT, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + input_cost_usd TEXT NOT NULL DEFAULT '0', + output_cost_usd TEXT NOT NULL DEFAULT '0', + cache_read_cost_usd TEXT NOT NULL DEFAULT '0', + cache_creation_cost_usd TEXT NOT NULL DEFAULT '0', + total_cost_usd TEXT NOT NULL DEFAULT '0', + latency_ms INTEGER NOT NULL, + status_code INTEGER NOT NULL, + cost_multiplier TEXT NOT NULL DEFAULT '1.0', + created_at INTEGER NOT NULL, + data_source TEXT NOT NULL DEFAULT 'proxy' + ); + INSERT INTO proxy_request_logs ( + request_id, provider_id, app_type, model, request_model, + input_tokens, output_tokens, total_cost_usd, latency_ms, status_code, created_at + ) VALUES ('log-1', 'provider-a', 'claude', 'kimi-k2', 'claude-sonnet-4', 10, 20, '0.03', 123, 200, 1); + CREATE TABLE usage_daily_rollups ( + date TEXT NOT NULL, + app_type TEXT NOT NULL, + provider_id TEXT NOT NULL, + model TEXT NOT NULL, + request_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + total_cost_usd TEXT NOT NULL DEFAULT '0', + avg_latency_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (date, app_type, provider_id, model) + ); + INSERT INTO usage_daily_rollups ( + date, app_type, provider_id, model, request_count, success_count, + input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, + total_cost_usd, avg_latency_ms + ) VALUES ('2026-06-01', 'claude', 'provider-a', 'kimi-k2', 2, 2, 100, 50, 7, 3, '0.42', 222); + "#, + ) + .expect("seed v10 schema"); + + Database::set_user_version(&conn, 10).expect("set user_version=10"); + Database::apply_schema_migrations_on_conn(&conn).expect("apply v11 migration"); + + assert_eq!( + Database::get_user_version(&conn).expect("version after migration"), + SCHEMA_VERSION + ); + assert!( + Database::has_column(&conn, "proxy_request_logs", "pricing_model") + .expect("check request pricing_model"), + "v11 migration should add proxy_request_logs.pricing_model" + ); + + let rollup: (String, String, i64, i64, String) = conn + .query_row( + "SELECT request_model, pricing_model, request_count, input_tokens, total_cost_usd + FROM usage_daily_rollups + WHERE date = '2026-06-01' AND app_type = 'claude' + AND provider_id = 'provider-a' AND model = 'kimi-k2'", + [], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + }, + ) + .expect("read migrated rollup"); + assert_eq!( + rollup, + ("".to_string(), "".to_string(), 2, 100, "0.42".to_string()) + ); } #[test] @@ -1812,6 +1938,105 @@ fn init_creates_config_dir_with_restrictive_permissions() { assert_eq!(dir_perms, 0o700, "new config dir should be 0o700"); } +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_symlinked_config_dir_without_writing_target() { + use std::os::unix::fs::symlink; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let external_dir = temp.path().join("external"); + let config_link = temp.path().join("cc-switch-link"); + std::fs::create_dir(&external_dir).expect("create external dir"); + symlink(&external_dir, &config_link).expect("create config symlink"); + + let _guard = ConfigDirEnvGuard::set(&config_link); + let err = match Database::init() { + Ok(_) => panic!("symlinked config dir should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_dir.join("cc-switch.db").exists(), + "init should not follow the config-dir symlink and create the database outside cc-switch" + ); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_symlinked_db_file_without_opening_target() { + use std::os::unix::fs::symlink; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("cc-switch"); + let external_db = temp.path().join("external.db"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::write(&external_db, b"not sqlite").expect("write external db"); + symlink(&external_db, config_dir.join("cc-switch.db")).expect("create db symlink"); + + let _guard = ConfigDirEnvGuard::set(&config_dir); + let err = match Database::init() { + Ok(_) => panic!("symlinked db file should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read(&external_db).expect("read external db"), + b"not sqlite", + "init should not open or modify the symlink target" + ); +} + +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_hardlinked_db_file_without_opening_target() { + use std::os::unix::fs::MetadataExt; + + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let config_dir = temp.path().join("cc-switch"); + let external_db = temp.path().join("external.db"); + let linked_db = config_dir.join("cc-switch.db"); + std::fs::create_dir(&config_dir).expect("create config dir"); + std::fs::write(&external_db, b"not sqlite").expect("write external db"); + std::fs::hard_link(&external_db, &linked_db).expect("create db hardlink"); + assert_eq!( + std::fs::metadata(&external_db) + .expect("metadata external db") + .nlink(), + 2, + "test setup should create a multi-link inode" + ); + + let _guard = ConfigDirEnvGuard::set(&config_dir); + let err = match Database::init() { + Ok(_) => panic!("hardlinked db file should be rejected"), + Err(err) => err, + }; + + assert!( + err.to_string().contains("硬链接") || err.to_string().contains("hardlink"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read(&external_db).expect("read external db"), + b"not sqlite", + "init should not open or modify a hardlinked database target" + ); +} + #[test] #[cfg(unix)] fn create_secure_dir_all_rejects_unresolved_parent_dir_components_without_chmodding_parent() { @@ -1826,7 +2051,7 @@ fn create_secure_dir_all_rejects_unresolved_parent_dir_components_without_chmodd let message = err.to_string(); assert!( - message.contains("配置目录路径无效") || message.contains("Invalid config directory path"), + message.contains("父目录组件") || message.contains("parent"), "unexpected error: {message}" ); assert!( @@ -1845,6 +2070,74 @@ fn create_secure_dir_all_rejects_unresolved_parent_dir_components_without_chmodd ); } +#[test] +#[serial_test::serial] +#[cfg(unix)] +fn init_rejects_parent_dir_config_path_even_when_child_exists() { + let _lock = crate::test_support::lock_test_home_and_settings(); + let temp = tempfile::tempdir().expect("create temp dir"); + let parent = temp.path().join("parent"); + let child = parent.join("child"); + std::fs::create_dir_all(&child).expect("create child dir"); + + let _guard = ConfigDirEnvGuard::set(&child.join("..")); + if Database::init().is_ok() { + panic!("parent-dir config path should be rejected"); + } + + assert!( + !parent.join("cc-switch.db").exists(), + "init must not normalize child/.. and create the database in the parent" + ); +} + +#[test] +#[cfg(unix)] +fn create_secure_dir_all_rejects_symlink_component_without_writing_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let external_dir = temp.path().join("external"); + let link_dir = temp.path().join("link"); + std::fs::create_dir(&external_dir).expect("create external dir"); + symlink(&external_dir, &link_dir).expect("create symlink"); + + let err = create_secure_dir_all(&link_dir.join("nested")) + .expect_err("symlink components should be rejected"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_dir.join("nested").exists(), + "rejected path should not create directories through the symlink target" + ); +} + +#[test] +#[cfg(unix)] +fn backup_database_connection_rejects_symlink_path_without_writing_target() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let backup_path = temp.path().join("backup.db"); + let external_target = temp.path().join("external.db"); + symlink(&external_target, &backup_path).expect("create dangling backup symlink"); + + let err = Database::create_backup_db_connection(&backup_path) + .expect_err("backup connection should reject symlink path"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !external_target.exists(), + "backup creation must not follow symlink target" + ); +} + #[test] #[serial_test::serial] #[cfg(unix)] diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 0ae89215..905bda6f 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -31,7 +31,7 @@ //! args: ["-y", "@modelcontextprotocol/server-filesystem"] //! ``` -use crate::config::{atomic_write, get_app_config_dir, home_dir}; +use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; use chrono::Local; @@ -273,7 +273,7 @@ fn replace_yaml_section( fn create_hermes_backup(source: &str) -> Result { let backup_dir = get_app_config_dir().join("backups").join("hermes"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + create_managed_config_dir_all(&backup_dir)?; let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); let mut filename = format!("{base_id}.yaml"); diff --git a/src-tauri/src/openclaw_config.rs b/src-tauri/src/openclaw_config.rs index 14633d3a..07825bba 100644 --- a/src-tauri/src/openclaw_config.rs +++ b/src-tauri/src/openclaw_config.rs @@ -1,4 +1,4 @@ -use crate::config::{atomic_write, get_app_config_dir, home_dir}; +use crate::config::{atomic_write, create_managed_config_dir_all, get_app_config_dir, home_dir}; use crate::error::AppError; use crate::provider::OpenClawProviderConfig; use crate::settings::{effective_backup_retain_count, get_openclaw_override_dir}; @@ -326,7 +326,7 @@ fn write_root_section(section: &str, value: &Value) -> Result Result { let backup_dir = get_app_config_dir().join("backups").join("openclaw"); - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + create_managed_config_dir_all(&backup_dir)?; let base_id = format!("openclaw_{}", Local::now().format("%Y%m%d_%H%M%S")); let mut filename = format!("{base_id}.json5"); diff --git a/src-tauri/src/proxy/usage/logger.rs b/src-tauri/src/proxy/usage/logger.rs index 41302dac..f4113585 100644 --- a/src-tauri/src/proxy/usage/logger.rs +++ b/src-tauri/src/proxy/usage/logger.rs @@ -211,18 +211,19 @@ async fn insert_request_log( match conn.execute( "INSERT OR REPLACE INTO proxy_request_logs ( - request_id, provider_id, app_type, model, request_model, + request_id, provider_id, app_type, model, request_model, pricing_model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_cost_usd, output_cost_usd, cache_read_cost_usd, cache_creation_cost_usd, total_cost_usd, latency_ms, first_token_ms, status_code, error_message, session_id, provider_type, is_streaming, cost_multiplier, created_at, data_source - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)", + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)", rusqlite::params![ request_id, &context.provider.id, context.app_type.as_str(), model, &context.request_model, + if cost.is_some() { pricing_model } else { "" }, usage.input_tokens, usage.output_tokens, usage.cache_read_tokens, diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index ad83d301..1a8ea491 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -1,5 +1,5 @@ use super::env_checker::EnvConflict; -use crate::config::get_app_config_dir; +use crate::config::{create_managed_config_dir_all, get_app_config_dir, write_json_file}; use chrono::Utc; use serde::{Deserialize, Serialize}; use std::fs; @@ -44,7 +44,7 @@ pub fn delete_env_vars(conflicts: Vec) -> Result Result { // Get backup directory let backup_dir = get_backup_dir()?; - fs::create_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; + create_managed_config_dir_all(&backup_dir).map_err(|e| format!("创建备份目录失败: {e}"))?; // Generate backup file name with timestamp let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string(); @@ -57,11 +57,7 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { conflicts: conflicts.to_vec(), }; - // Write backup file - let json = serde_json::to_string_pretty(&backup_info) - .map_err(|e| format!("序列化备份数据失败: {e}"))?; - - fs::write(&backup_file, json).map_err(|e| format!("写入备份文件失败: {e}"))?; + write_json_file(&backup_file, &backup_info).map_err(|e| format!("写入备份文件失败: {e}"))?; Ok(backup_info) } @@ -237,4 +233,47 @@ mod tests { let backup_dir = get_backup_dir(); assert!(backup_dir.is_ok()); } + + #[cfg(unix)] + #[test] + fn env_backup_json_is_written_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let backup = create_backup(&[]).expect("create backup"); + let backup_path = PathBuf::from(&backup.backup_path); + let mode = std::fs::metadata(&backup_path) + .expect("metadata backup") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert!( + crate::config::check_permissions().is_empty(), + "fresh env backup should not be reported as insecure" + ); + } + + #[cfg(unix)] + #[test] + fn env_backup_rejects_parent_dir_config_path_before_creating_intermediate_dirs() { + let root = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(root.path()); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.path().join("child").join("..")); + } + + create_backup(&[]).expect_err("invalid config dir should be rejected before backup"); + + assert!( + !root.path().join("child").exists(), + "backup creation must not pre-create unvalidated path components" + ); + assert!( + !root.path().join("backups").exists(), + "backup creation must not write to the normalized parent directory" + ); + } } diff --git a/src-tauri/src/services/provider/codex_openai_auth_tests.rs b/src-tauri/src/services/provider/codex_openai_auth_tests.rs index cb785bad..c37a2911 100644 --- a/src-tauri/src/services/provider/codex_openai_auth_tests.rs +++ b/src-tauri/src/services/provider/codex_openai_auth_tests.rs @@ -1,56 +1,14 @@ use super::*; use serial_test::serial; -use std::ffi::OsString; -use std::path::Path; use tempfile::TempDir; -use crate::test_support::{ - lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock, -}; - -struct EnvGuard { - _lock: TestHomeSettingsLock, - old_home: Option, - old_userprofile: Option, -} - -impl EnvGuard { - fn set_home(home: &Path) -> Self { - let lock = lock_test_home_and_settings(); - let old_home = std::env::var_os("HOME"); - let old_userprofile = std::env::var_os("USERPROFILE"); - std::env::set_var("HOME", home); - std::env::set_var("USERPROFILE", home); - set_test_home_override(Some(home)); - crate::settings::reload_test_settings(); - Self { - _lock: lock, - old_home, - old_userprofile, - } - } -} - -impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.old_home { - Some(value) => std::env::set_var("HOME", value), - None => std::env::remove_var("HOME"), - } - match &self.old_userprofile { - Some(value) => std::env::set_var("USERPROFILE", value), - None => std::env::remove_var("USERPROFILE"), - } - set_test_home_override(self.old_home.as_deref().map(Path::new)); - crate::settings::reload_test_settings(); - } -} +use crate::test_support::TestEnvGuard; #[test] #[serial] fn switch_codex_provider_writes_stored_config_directly() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) .expect("create ~/.codex (initialized)"); @@ -97,7 +55,7 @@ fn switch_codex_provider_writes_stored_config_directly() { #[serial] fn switch_codex_provider_migrates_legacy_flat_config() { let temp_home = TempDir::new().expect("create temp home"); - let _env = EnvGuard::set_home(temp_home.path()); + let _env = TestEnvGuard::isolated(temp_home.path()); std::fs::create_dir_all(crate::codex_config::get_codex_config_dir()) .expect("create ~/.codex (initialized)"); diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index b55b9485..35192b2d 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -17,7 +17,7 @@ use tokio::time::timeout; use crate::app_config::AppType; pub use crate::app_config::{InstalledSkill, SkillApps, UnmanagedSkill}; -use crate::config::get_app_config_dir; +use crate::config::{create_managed_config_dir_all, get_app_config_dir}; use crate::database::Database; use crate::error::{format_skill_error, AppError}; @@ -431,7 +431,7 @@ impl SkillService { pub fn get_ssot_dir() -> Result { let dir = get_app_config_dir().join("skills"); - fs::create_dir_all(&dir).map_err(|e| AppError::io(&dir, e))?; + create_managed_config_dir_all(&dir)?; Ok(dir) } diff --git a/src-tauri/src/services/webdav_sync/mod.rs b/src-tauri/src/services/webdav_sync/mod.rs index 98d5f36c..04b9dc1a 100644 --- a/src-tauri/src/services/webdav_sync/mod.rs +++ b/src-tauri/src/services/webdav_sync/mod.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tempfile::tempdir; -use crate::database::Database; +use crate::database::{Database, SCHEMA_VERSION}; use crate::error::AppError; use crate::services::webdav; use crate::settings::{ @@ -576,13 +576,15 @@ fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> { format!("SQL is not valid UTF-8: {e}"), ) })?; + validate_sql_user_version_for_import(sql_str)?; let skills_backup = SkillsBackup::backup_current_skills()?; // 先替换 skills,再导入数据库;若导入失败则回滚 skills,避免"半恢复"。 restore_skills_zip(skills_zip)?; - if let Err(db_err) = Database::init()?.import_sql_string_for_sync(sql_str) { + let import_result = Database::init().and_then(|db| db.import_sql_string_for_sync(sql_str)); + if let Err(db_err) = import_result { if let Err(rollback_err) = skills_backup.restore() { return Err(localized( "webdav.sync.db_import_and_rollback_failed", @@ -598,6 +600,39 @@ fn apply_snapshot(db_sql: &[u8], skills_zip: &[u8]) -> Result<(), AppError> { Ok(()) } +fn validate_sql_user_version_for_import(sql: &str) -> Result<(), AppError> { + let Some(version) = extract_sql_user_version(sql) else { + return Ok(()); + }; + + if version > SCHEMA_VERSION { + return Err(localized( + "webdav.sync.db_schema_too_new", + format!( + "远端数据库版本过新({version}),当前应用仅支持 {SCHEMA_VERSION},请先升级应用后再同步" + ), + format!( + "Remote database schema is too new ({version}); this app supports up to {SCHEMA_VERSION}. Upgrade before syncing." + ), + )); + } + + Ok(()) +} + +fn extract_sql_user_version(sql: &str) -> Option { + sql.lines().find_map(|line| { + let trimmed = line.trim_start_matches('\u{feff}').trim(); + let value = trimmed + .strip_prefix("PRAGMA user_version") + .and_then(|rest| rest.trim_start().strip_prefix('=')) + .map(|rest| rest.trim().trim_end_matches(';').trim()) + .or_else(|| trimmed.strip_prefix("-- user_version:").map(str::trim))?; + + value.parse::().ok() + }) +} + // --------------------------------------------------------------------------- // 同步状态持久化 // --------------------------------------------------------------------------- @@ -1142,6 +1177,253 @@ mod tests { assert!(validate_artifact_size_limit("db.sql", MAX_SYNC_ARTIFACT_BYTES + 1).is_err()); } + #[test] + fn extract_sql_user_version_reads_pragma_and_comment() { + assert_eq!( + extract_sql_user_version("-- header\nPRAGMA user_version=10;\n"), + Some(10) + ); + assert_eq!( + extract_sql_user_version("-- user_version: 11\nPRAGMA foreign_keys=OFF;\n"), + Some(11) + ); + } + + #[test] + fn validate_sql_user_version_rejects_future_schema_before_restore() { + let sql = format!( + "-- CC Switch SQLite 导出\nPRAGMA user_version={};\n", + SCHEMA_VERSION + 1 + ); + let err = validate_sql_user_version_for_import(&sql) + .expect_err("future schema should be rejected before applying snapshot"); + assert!( + err.to_string().contains("版本过新") || err.to_string().contains("schema is too new"), + "unexpected error: {err}" + ); + } + + #[test] + fn apply_snapshot_accepts_current_schema_v11_sync_export() -> Result<(), AppError> { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let remote_db = Database::memory().expect("create remote db"); + { + let conn = crate::database::lock_conn!(remote_db.conn); + conn.execute( + "INSERT INTO providers (id, app_type, name, settings_config, meta) + VALUES ('remote-provider', 'claude', 'Remote Provider', '{}', '{}')", + [], + ) + .expect("insert remote provider"); + } + let db_sql = remote_db + .export_sql_string_for_sync() + .expect("export v11 sync sql"); + + let zip_path = temp.path().join("remote-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create remote skills zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "remote-skill/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start remote skill"); + use std::io::Write; + writer.write_all(b"remote").expect("write remote skill"); + writer.finish().expect("finish remote skills zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read remote skills zip"); + + apply_snapshot(db_sql.as_bytes(), &skills_zip).expect("apply current-schema snapshot"); + + let local_db = Database::init().expect("open local db"); + let conn = crate::database::lock_conn!(local_db.conn); + let provider_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM providers WHERE id = 'remote-provider'", + [], + |row| row.get(0), + ) + .expect("count imported provider"); + assert_eq!(provider_count, 1); + assert!( + crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("remote-skill") + .join("SKILL.md") + .exists(), + "current-schema restore should unpack remote skills" + ); + Ok(()) + } + + #[test] + fn apply_snapshot_rejects_future_schema_without_touching_existing_skills() { + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let existing_skill = crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("existing") + .join("SKILL.md"); + std::fs::create_dir_all(existing_skill.parent().expect("skill parent")) + .expect("create existing skill parent"); + std::fs::write(&existing_skill, "existing").expect("write existing skill"); + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + let sql = format!( + "-- CC Switch SQLite 导出\nPRAGMA user_version={};\n", + SCHEMA_VERSION + 1 + ); + + apply_snapshot(sql.as_bytes(), &skills_zip) + .expect_err("future schema should be rejected before restoring skills"); + + assert_eq!( + std::fs::read_to_string(&existing_skill).expect("read existing skill"), + "existing", + "future schema restore must leave existing skills untouched" + ); + assert!( + !crate::services::skill::SkillService::get_ssot_dir() + .expect("ssot dir") + .join("replacement") + .exists(), + "future schema restore must not unpack replacement skills" + ); + } + + #[cfg(unix)] + #[test] + fn apply_snapshot_rolls_back_skills_when_database_init_fails() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + + let ssot = crate::services::skill::SkillService::get_ssot_dir().expect("ssot dir"); + let existing_skill = ssot.join("existing").join("SKILL.md"); + std::fs::create_dir_all(existing_skill.parent().expect("skill parent")) + .expect("create existing skill parent"); + std::fs::write(&existing_skill, "existing").expect("write existing skill"); + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + + let db_target = temp.path().join("external.db"); + std::fs::write(&db_target, b"not sqlite").expect("write external db"); + let db_link = temp.path().join(".cc-switch").join("cc-switch.db"); + symlink(&db_target, &db_link).expect("create db symlink"); + + apply_snapshot( + b"-- CC Switch SQLite export\nPRAGMA user_version=0;\n", + &skills_zip, + ) + .expect_err("db init failure should fail the restore"); + + assert_eq!( + std::fs::read_to_string(&existing_skill).expect("read existing skill"), + "existing", + "DB init failure must roll back restored skills" + ); + assert!( + !ssot.join("replacement").exists(), + "DB init failure must remove replacement skills" + ); + } + + #[cfg(unix)] + #[test] + fn apply_snapshot_rejects_symlink_parent_config_dir_before_restoring_skills() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("create temp dir"); + let _env = crate::test_support::TestEnvGuard::isolated(temp.path()); + let real_parent = temp.path().join("real-parent"); + let external_parent = temp.path().join("external-parent"); + std::fs::create_dir(&real_parent).expect("create real parent"); + std::fs::create_dir(&external_parent).expect("create external parent"); + symlink(&external_parent, real_parent.join("link")).expect("create symlink parent"); + unsafe { + std::env::set_var( + "CC_SWITCH_CONFIG_DIR", + real_parent.join("link").join("..").join("cc-switch"), + ); + } + + let zip_path = temp.path().join("replacement-skills.zip"); + { + let file = std::fs::File::create(&zip_path).expect("create replacement zip"); + let mut writer = zip::ZipWriter::new(file); + writer + .start_file( + "replacement/SKILL.md", + crate::services::webdav_sync::archive::zip_file_options(), + ) + .expect("start replacement skill"); + use std::io::Write; + writer + .write_all(b"replacement") + .expect("write replacement skill"); + writer.finish().expect("finish replacement zip"); + } + let skills_zip = std::fs::read(&zip_path).expect("read replacement zip"); + + let err = apply_snapshot( + b"-- CC Switch SQLite export\nPRAGMA user_version=0;\n", + &skills_zip, + ) + .expect_err("symlink parent config dir should fail before restoring skills"); + + assert!( + err.to_string().contains("符号链接") || err.to_string().contains("symlink"), + "unexpected error: {err}" + ); + assert!( + !real_parent.join("cc-switch/skills").exists(), + "restore must not create the normalized skills directory" + ); + assert!( + !external_parent.join("cc-switch/skills").exists(), + "restore must not follow the symlinked parent into external storage" + ); + } + #[test] fn normalize_device_name_trims() { assert_eq!( diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b974b574..12f83692 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,5 +1,5 @@ use crate::app_config::AppType; -use crate::config::{get_app_config_dir, home_dir}; +use crate::config::{get_app_config_dir, home_dir, write_json_file}; use crate::error::AppError; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -602,13 +602,7 @@ impl AppSettings { normalized.validate()?; let path = Self::settings_path(); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - let json = serde_json::to_string_pretty(&normalized) - .map_err(|e| AppError::JsonSerialize { source: e })?; - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; + write_json_file(&path, &normalized)?; Ok(()) } } diff --git a/src-tauri/tests/openclaw_config.rs b/src-tauri/tests/openclaw_config.rs index a90fc414..2e28b18b 100644 --- a/src-tauri/tests/openclaw_config.rs +++ b/src-tauri/tests/openclaw_config.rs @@ -81,6 +81,10 @@ mod config { .join(".cc-switch") } + pub(crate) fn create_managed_config_dir_all(path: &Path) -> Result<(), AppError> { + fs::create_dir_all(path).map_err(|err| AppError::io(path, err)) + } + pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| AppError::io(parent, err))?; diff --git a/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs b/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs index 77f0e0e4..05cb0922 100644 --- a/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs +++ b/src-tauri/tests/proxy_claude_openai_chat/error_cases.rs @@ -108,9 +108,10 @@ async fn proxy_claude_openai_chat_non_success_error_is_transformed_to_anthropic_ assert_eq!(log_values[1], "claude-openai-chat-error"); assert_eq!(log_values[3], "claude-3-7-sonnet"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[17], "400"); - assert_eq!(log_values[19], "claude-session-non-success"); - assert!(log_values[18].contains("upstream rejected the request")); + assert_eq!(log_values[5], ""); + assert_eq!(log_values[18], "400"); + assert_eq!(log_values[20], "claude-session-non-success"); + assert!(log_values[19].contains("upstream rejected the request")); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs b/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs index ea12ce16..28a5f613 100644 --- a/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs +++ b/src-tauri/tests/proxy_claude_openai_chat/logging_cases.rs @@ -96,14 +96,15 @@ async fn proxy_claude_openai_chat_success_logs_request_with_session_id_and_usage assert_eq!(log_values[1], "claude-openai-chat-log-success"); assert_eq!(log_values[3], "gpt-5.2"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[5], "11"); - assert_eq!(log_values[6], "7"); - assert_eq!(log_values[9], "0.00001925"); - assert_eq!(log_values[10], "0.000098"); - assert_eq!(log_values[13], "0.0002345"); - assert_eq!(log_values[17], "201"); - assert_eq!(log_values[19], "claude-session-success"); - assert_eq!(log_values[22], "2"); + assert_eq!(log_values[5], "gpt-5.2"); + assert_eq!(log_values[6], "11"); + assert_eq!(log_values[7], "7"); + assert_eq!(log_values[10], "0.00001925"); + assert_eq!(log_values[11], "0.000098"); + assert_eq!(log_values[14], "0.0002345"); + assert_eq!(log_values[18], "201"); + assert_eq!(log_values[20], "claude-session-success"); + assert_eq!(log_values[23], "2"); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); @@ -202,9 +203,10 @@ async fn proxy_claude_buffered_transform_failure_logs_error_request_with_session assert_eq!(log_values[1], "claude-openai-chat-log-failure"); assert_eq!(log_values[3], "claude-3-7-sonnet"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[17], "502"); - assert_eq!(log_values[19], "claude-session-failure"); - assert!(log_values[18].contains("parse upstream json failed")); + assert_eq!(log_values[5], ""); + assert_eq!(log_values[18], "502"); + assert_eq!(log_values[20], "claude-session-failure"); + assert!(log_values[19].contains("parse upstream json failed")); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/proxy_claude_streaming/logging_cases.rs b/src-tauri/tests/proxy_claude_streaming/logging_cases.rs index 60238a21..0af73328 100644 --- a/src-tauri/tests/proxy_claude_streaming/logging_cases.rs +++ b/src-tauri/tests/proxy_claude_streaming/logging_cases.rs @@ -182,15 +182,16 @@ async fn stream_openai_chat_logs_request_with_session_id_and_usage() { assert_eq!(log_values[1], "claude-openai-chat-stream-log"); assert_eq!(log_values[3], "gpt-5.2"); assert_eq!(log_values[4], "claude-3-7-sonnet"); - assert_eq!(log_values[5], "11"); - assert_eq!(log_values[6], "7"); - assert_eq!(log_values[9], "0.00001925"); - assert_eq!(log_values[10], "0.000098"); - assert_eq!(log_values[13], "0.0002345"); - assert_eq!(log_values[17], "200"); - assert_eq!(log_values[19], "claude-stream-session"); - assert_eq!(log_values[21], "1"); - assert_eq!(log_values[22], "2"); + assert_eq!(log_values[5], "gpt-5.2"); + assert_eq!(log_values[6], "11"); + assert_eq!(log_values[7], "7"); + assert_eq!(log_values[10], "0.00001925"); + assert_eq!(log_values[11], "0.000098"); + assert_eq!(log_values[14], "0.0002345"); + assert_eq!(log_values[18], "200"); + assert_eq!(log_values[20], "claude-stream-session"); + assert_eq!(log_values[22], "1"); + assert_eq!(log_values[23], "2"); service.stop().await.expect("stop proxy service"); upstream_handle.abort(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 7c927b0b..9050bef6 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -40,7 +40,11 @@ mod claude_mcp { } mod config { - use std::path::PathBuf; + use serde::Serialize; + use std::fs; + use std::path::{Path, PathBuf}; + + use crate::error::AppError; pub(crate) fn home_dir() -> Option { dirs::home_dir() @@ -56,6 +60,15 @@ mod config { home_dir().expect("无法获取用户主目录").join(".cc-switch") } + + pub(crate) fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let json = serde_json::to_string_pretty(data) + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(path, json).map_err(|e| AppError::io(path, e)) + } } mod database { diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index e80015dc..47faa3c8 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -43,7 +43,11 @@ mod claude_mcp { } mod config { - use std::path::PathBuf; + use serde::Serialize; + use std::fs; + use std::path::{Path, PathBuf}; + + use crate::error::AppError; pub(crate) fn home_dir() -> Option { dirs::home_dir() @@ -59,6 +63,15 @@ mod config { home_dir().expect("无法获取用户主目录").join(".cc-switch") } + + pub(crate) fn write_json_file(path: &Path, data: &T) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + let json = serde_json::to_string_pretty(data) + .map_err(|e| AppError::JsonSerialize { source: e })?; + fs::write(path, json).map_err(|e| AppError::io(path, e)) + } } mod database { diff --git a/src-tauri/tests/webdav_settings.rs b/src-tauri/tests/webdav_settings.rs index 0db7de70..d793d816 100644 --- a/src-tauri/tests/webdav_settings.rs +++ b/src-tauri/tests/webdav_settings.rs @@ -1,6 +1,6 @@ use cc_switch_lib::{ - get_webdav_sync_settings, set_webdav_sync_settings, webdav_jianguoyun_preset, - WebDavSyncSettings, WebDavSyncStatus, + check_permissions, get_app_config_dir, get_webdav_sync_settings, set_webdav_sync_settings, + webdav_jianguoyun_preset, WebDavSyncSettings, WebDavSyncStatus, }; #[path = "support.rs"] @@ -56,6 +56,55 @@ fn set_webdav_sync_settings_persists_and_normalizes_fields() { assert_eq!(saved.profile, "default"); } +#[cfg(unix)] +#[test] +fn set_webdav_sync_settings_writes_sensitive_settings_json_as_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let _guard = lock_test_mutex(); + reset_test_fs(); + let _home = ensure_test_home(); + + set_webdav_sync_settings(Some(sample_settings())).expect("save webdav settings"); + + let settings_path = get_app_config_dir().join("settings.json"); + let mode = std::fs::metadata(&settings_path) + .expect("metadata settings.json") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + assert!( + check_permissions().is_empty(), + "fresh settings write should not be reported as insecure" + ); +} + +#[cfg(unix)] +#[test] +fn set_webdav_sync_settings_rejects_parent_dir_config_path_before_creating_dirs() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let home = ensure_test_home(); + let root = home.join("invalid-config-root"); + std::fs::create_dir(&root).expect("create invalid config root"); + unsafe { + std::env::set_var("CC_SWITCH_CONFIG_DIR", root.join("child").join("..")); + } + + set_webdav_sync_settings(Some(sample_settings())) + .expect_err("invalid config dir should be rejected before writing settings"); + + assert!( + !root.join("child").exists(), + "settings save must not pre-create unvalidated path components" + ); + assert!( + !root.join("settings.json").exists(), + "settings save must not write to the normalized parent directory" + ); +} + #[test] fn set_webdav_sync_settings_can_clear_config() { let _guard = lock_test_mutex();